@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.
Files changed (83) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +345 -0
  3. package/dist/client/_generated/_ignore.d.ts +1 -0
  4. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  5. package/dist/client/_generated/_ignore.js +3 -0
  6. package/dist/client/_generated/_ignore.js.map +1 -0
  7. package/dist/client/index.d.ts +320 -0
  8. package/dist/client/index.d.ts.map +1 -0
  9. package/dist/client/index.js +509 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/component/_generated/api.d.ts +44 -0
  12. package/dist/component/_generated/api.d.ts.map +1 -0
  13. package/dist/component/_generated/api.js +31 -0
  14. package/dist/component/_generated/api.js.map +1 -0
  15. package/dist/component/_generated/component.d.ts +694 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -0
  17. package/dist/component/_generated/component.js +11 -0
  18. package/dist/component/_generated/component.js.map +1 -0
  19. package/dist/component/_generated/dataModel.d.ts +46 -0
  20. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  21. package/dist/component/_generated/dataModel.js +11 -0
  22. package/dist/component/_generated/dataModel.js.map +1 -0
  23. package/dist/component/_generated/server.d.ts +121 -0
  24. package/dist/component/_generated/server.d.ts.map +1 -0
  25. package/dist/component/_generated/server.js +78 -0
  26. package/dist/component/_generated/server.js.map +1 -0
  27. package/dist/component/config.d.ts +260 -0
  28. package/dist/component/config.d.ts.map +1 -0
  29. package/dist/component/config.js +154 -0
  30. package/dist/component/config.js.map +1 -0
  31. package/dist/component/convex.config.d.ts +3 -0
  32. package/dist/component/convex.config.d.ts.map +1 -0
  33. package/dist/component/convex.config.js +3 -0
  34. package/dist/component/convex.config.js.map +1 -0
  35. package/dist/component/identity.d.ts +98 -0
  36. package/dist/component/identity.d.ts.map +1 -0
  37. package/dist/component/identity.js +371 -0
  38. package/dist/component/identity.js.map +1 -0
  39. package/dist/component/lib.d.ts +4 -0
  40. package/dist/component/lib.d.ts.map +1 -0
  41. package/dist/component/lib.js +4 -0
  42. package/dist/component/lib.js.map +1 -0
  43. package/dist/component/providers/fly.d.ts +39 -0
  44. package/dist/component/providers/fly.d.ts.map +1 -0
  45. package/dist/component/providers/fly.js +145 -0
  46. package/dist/component/providers/fly.js.map +1 -0
  47. package/dist/component/queue.d.ts +294 -0
  48. package/dist/component/queue.d.ts.map +1 -0
  49. package/dist/component/queue.js +1278 -0
  50. package/dist/component/queue.js.map +1 -0
  51. package/dist/component/scheduler.d.ts +89 -0
  52. package/dist/component/scheduler.d.ts.map +1 -0
  53. package/dist/component/scheduler.js +275 -0
  54. package/dist/component/scheduler.js.map +1 -0
  55. package/dist/component/schema.d.ts +518 -0
  56. package/dist/component/schema.d.ts.map +1 -0
  57. package/dist/component/schema.js +243 -0
  58. package/dist/component/schema.js.map +1 -0
  59. package/dist/react/index.d.ts +2 -0
  60. package/dist/react/index.d.ts.map +1 -0
  61. package/dist/react/index.js +6 -0
  62. package/dist/react/index.js.map +1 -0
  63. package/package.json +102 -0
  64. package/src/client/_generated/_ignore.ts +1 -0
  65. package/src/client/index.test.ts +48 -0
  66. package/src/client/index.ts +586 -0
  67. package/src/client/setup.test.ts +26 -0
  68. package/src/component/_generated/api.ts +60 -0
  69. package/src/component/_generated/component.ts +902 -0
  70. package/src/component/_generated/dataModel.ts +60 -0
  71. package/src/component/_generated/server.ts +156 -0
  72. package/src/component/config.ts +236 -0
  73. package/src/component/convex.config.ts +3 -0
  74. package/src/component/identity.ts +450 -0
  75. package/src/component/lib.test.ts +319 -0
  76. package/src/component/lib.ts +30 -0
  77. package/src/component/providers/fly.ts +226 -0
  78. package/src/component/queue.ts +1544 -0
  79. package/src/component/scheduler.ts +362 -0
  80. package/src/component/schema.ts +336 -0
  81. package/src/component/setup.test.ts +11 -0
  82. package/src/react/index.ts +7 -0
  83. package/src/test.ts +18 -0
@@ -0,0 +1,1544 @@
1
+ import { v } from "convex/values";
2
+ import { internal } from "./_generated/api.js";
3
+ import {
4
+ internalMutation,
5
+ internalQuery,
6
+ mutation,
7
+ query,
8
+ } from "./_generated/server.js";
9
+ import { computeRetryDelayMs, DEFAULT_CONFIG } from "./config.js";
10
+
11
+ const queueStatusValidator = v.union(
12
+ v.literal("queued"),
13
+ v.literal("processing"),
14
+ v.literal("done"),
15
+ v.literal("failed"),
16
+ v.literal("dead_letter"),
17
+ );
18
+
19
+ const queuePayloadValidator = v.object({
20
+ provider: v.string(),
21
+ providerUserId: v.string(),
22
+ messageText: v.string(),
23
+ externalMessageId: v.optional(v.string()),
24
+ rawUpdateJson: v.optional(v.string()),
25
+ metadata: v.optional(v.record(v.string(), v.string())),
26
+ });
27
+
28
+ const snapshotReasonValidator = v.union(
29
+ v.literal("drain"),
30
+ v.literal("signal"),
31
+ v.literal("manual"),
32
+ );
33
+ const DATA_SNAPSHOT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
34
+
35
+ const claimedJobValidator = v.object({
36
+ messageId: v.id("messageQueue"),
37
+ conversationId: v.string(),
38
+ agentKey: v.string(),
39
+ leaseId: v.string(),
40
+ leaseExpiresAt: v.number(),
41
+ payload: queuePayloadValidator,
42
+ });
43
+
44
+ const secretStatusValidator = v.object({
45
+ secretRef: v.string(),
46
+ hasActive: v.boolean(),
47
+ version: v.union(v.null(), v.number()),
48
+ });
49
+
50
+ export const enqueueMessage = mutation({
51
+ args: {
52
+ conversationId: v.string(),
53
+ agentKey: v.string(),
54
+ payload: queuePayloadValidator,
55
+ priority: v.optional(v.number()),
56
+ scheduledFor: v.optional(v.number()),
57
+ maxAttempts: v.optional(v.number()),
58
+ nowMs: v.optional(v.number()),
59
+ },
60
+ returns: v.id("messageQueue"),
61
+ handler: async (ctx, args) => {
62
+ const nowMs = args.nowMs ?? Date.now();
63
+ const profile = await ctx.db
64
+ .query("agentProfiles")
65
+ .withIndex("by_agentKey", (q) => q.eq("agentKey", args.agentKey))
66
+ .unique();
67
+ if (!profile || !profile.enabled) {
68
+ throw new Error(`Agent profile '${args.agentKey}' not found or disabled`);
69
+ }
70
+
71
+ const existingConversation = await ctx.db
72
+ .query("conversations")
73
+ .withIndex("by_conversationId", (q) =>
74
+ q.eq("conversationId", args.conversationId),
75
+ )
76
+ .unique();
77
+
78
+ if (!existingConversation) {
79
+ await ctx.db.insert("conversations", {
80
+ conversationId: args.conversationId,
81
+ agentKey: args.agentKey,
82
+ contextHistory: [],
83
+ pendingToolCalls: [],
84
+ });
85
+ }
86
+
87
+ const priority = Math.min(
88
+ DEFAULT_CONFIG.queue.maxPriority,
89
+ Math.max(0, args.priority ?? DEFAULT_CONFIG.queue.defaultPriority),
90
+ );
91
+
92
+ const messageId = await ctx.db.insert("messageQueue", {
93
+ conversationId: args.conversationId,
94
+ agentKey: args.agentKey,
95
+ payload: args.payload,
96
+ status: "queued",
97
+ priority,
98
+ scheduledFor: args.scheduledFor ?? nowMs,
99
+ attempts: 0,
100
+ maxAttempts: args.maxAttempts ?? DEFAULT_CONFIG.retry.maxAttempts,
101
+ });
102
+ try {
103
+ await ctx.scheduler.runAfter(0, (internal.scheduler as any).reconcileWorkerPoolFromEnqueue, {
104
+ workspaceId: "default",
105
+ });
106
+ } catch (error) {
107
+ console.warn(
108
+ `[queue] failed to schedule reconcile after enqueue: ${
109
+ error instanceof Error ? error.message : String(error)
110
+ }`,
111
+ );
112
+ }
113
+ return messageId;
114
+ },
115
+ });
116
+
117
+ export const appendConversationMessages = mutation({
118
+ args: {
119
+ conversationId: v.string(),
120
+ workspaceId: v.optional(v.string()),
121
+ messages: v.array(
122
+ v.object({
123
+ role: v.union(
124
+ v.literal("system"),
125
+ v.literal("user"),
126
+ v.literal("assistant"),
127
+ v.literal("tool"),
128
+ ),
129
+ content: v.string(),
130
+ at: v.optional(v.number()),
131
+ }),
132
+ ),
133
+ nowMs: v.optional(v.number()),
134
+ },
135
+ returns: v.object({
136
+ updated: v.boolean(),
137
+ messageCount: v.number(),
138
+ }),
139
+ handler: async (ctx, args) => {
140
+ const conversation = await ctx.db
141
+ .query("conversations")
142
+ .withIndex("by_conversationId", (q) => q.eq("conversationId", args.conversationId))
143
+ .unique();
144
+ if (!conversation) {
145
+ return { updated: false, messageCount: 0 };
146
+ }
147
+ const nowMs = args.nowMs ?? Date.now();
148
+ const messages = args.messages.map((message, index) => ({
149
+ role: message.role,
150
+ content: message.content,
151
+ at: message.at ?? nowMs + index,
152
+ }));
153
+ const nextContextHistory = [...conversation.contextHistory, ...messages];
154
+ await ctx.db.patch(conversation._id, { contextHistory: nextContextHistory });
155
+ const snapshotKey = `${args.workspaceId ?? "default"}:${conversation.agentKey}`;
156
+ const cache = await ctx.db
157
+ .query("conversationHydrationCache")
158
+ .withIndex("by_conversationId", (q) => q.eq("conversationId", args.conversationId))
159
+ .first();
160
+ const deltaContext = nextContextHistory.slice(-64);
161
+ const deltaFingerprint = fingerprintConversationDelta(deltaContext);
162
+ if (!cache) {
163
+ await ctx.db.insert("conversationHydrationCache", {
164
+ conversationId: args.conversationId,
165
+ agentKey: conversation.agentKey,
166
+ snapshotKey,
167
+ lastHydratedAt: nowMs,
168
+ deltaContext,
169
+ deltaFingerprint,
170
+ });
171
+ } else {
172
+ await ctx.db.patch(cache._id, {
173
+ agentKey: conversation.agentKey,
174
+ snapshotKey,
175
+ lastHydratedAt: nowMs,
176
+ deltaContext,
177
+ deltaFingerprint,
178
+ });
179
+ }
180
+ return { updated: true, messageCount: messages.length };
181
+ },
182
+ });
183
+
184
+ export const upsertAgentProfile = mutation({
185
+ args: {
186
+ agentKey: v.string(),
187
+ version: v.string(),
188
+ soulMd: v.string(),
189
+ clientMd: v.optional(v.string()),
190
+ skills: v.array(v.string()),
191
+ secretsRef: v.array(v.string()),
192
+ enabled: v.boolean(),
193
+ },
194
+ returns: v.id("agentProfiles"),
195
+ handler: async (ctx, args) => {
196
+ const existing = await ctx.db
197
+ .query("agentProfiles")
198
+ .withIndex("by_agentKey", (q) => q.eq("agentKey", args.agentKey))
199
+ .unique();
200
+ if (!existing) {
201
+ return await ctx.db.insert("agentProfiles", args);
202
+ }
203
+ await ctx.db.patch(existing._id, args);
204
+ return existing._id;
205
+ },
206
+ });
207
+
208
+ export const importPlaintextSecret = mutation({
209
+ args: {
210
+ secretRef: v.string(),
211
+ plaintextValue: v.string(),
212
+ metadata: v.optional(v.record(v.string(), v.string())),
213
+ },
214
+ returns: v.object({
215
+ secretId: v.id("secrets"),
216
+ secretRef: v.string(),
217
+ version: v.number(),
218
+ }),
219
+ handler: async (ctx, args) => {
220
+ const history = await ctx.db
221
+ .query("secrets")
222
+ .withIndex("by_secretRef", (q) => q.eq("secretRef", args.secretRef))
223
+ .collect();
224
+
225
+ const nextVersion =
226
+ history.reduce((maxVersion, row) => Math.max(maxVersion, row.version), 0) + 1;
227
+ const previousActive = history.find((row) => row.active);
228
+ const encoded = encryptSecretValue(args.plaintextValue);
229
+
230
+ for (const row of history) {
231
+ if (row.active) {
232
+ await ctx.db.patch(row._id, { active: false });
233
+ }
234
+ }
235
+
236
+ const secretId = await ctx.db.insert("secrets", {
237
+ secretRef: args.secretRef,
238
+ version: nextVersion,
239
+ encryptedValue: encoded,
240
+ keyId: "component-local",
241
+ algorithm: "xor-hex-v1",
242
+ active: true,
243
+ rotatedFrom: previousActive?.version,
244
+ metadata: args.metadata,
245
+ });
246
+
247
+ return {
248
+ secretId,
249
+ secretRef: args.secretRef,
250
+ version: nextVersion,
251
+ };
252
+ },
253
+ });
254
+
255
+ export const getSecretsStatus = query({
256
+ args: {
257
+ secretRefs: v.array(v.string()),
258
+ },
259
+ returns: v.array(secretStatusValidator),
260
+ handler: async (ctx, args) => {
261
+ const statuses: Array<{
262
+ secretRef: string;
263
+ hasActive: boolean;
264
+ version: number | null;
265
+ }> = [];
266
+ for (const ref of args.secretRefs) {
267
+ const active = await ctx.db
268
+ .query("secrets")
269
+ .withIndex("by_secretRef_and_active", (q) =>
270
+ q.eq("secretRef", ref).eq("active", true),
271
+ )
272
+ .unique();
273
+ statuses.push({
274
+ secretRef: ref,
275
+ hasActive: active !== null,
276
+ version: active?.version ?? null,
277
+ });
278
+ }
279
+ return statuses;
280
+ },
281
+ });
282
+
283
+ export const getActiveSecretPlaintext = internalQuery({
284
+ args: {
285
+ secretRef: v.string(),
286
+ },
287
+ returns: v.union(v.null(), v.string()),
288
+ handler: async (ctx, args) => {
289
+ const active = await ctx.db
290
+ .query("secrets")
291
+ .withIndex("by_secretRef_and_active", (q) =>
292
+ q.eq("secretRef", args.secretRef).eq("active", true),
293
+ )
294
+ .unique();
295
+ if (!active) {
296
+ return null;
297
+ }
298
+ return decryptSecretValue(active.encryptedValue, active.algorithm);
299
+ },
300
+ });
301
+
302
+ export const generateMediaUploadUrl = mutation({
303
+ args: {},
304
+ returns: v.object({
305
+ uploadUrl: v.string(),
306
+ }),
307
+ handler: async (ctx) => {
308
+ const uploadUrl = await ctx.storage.generateUploadUrl();
309
+ return { uploadUrl };
310
+ },
311
+ });
312
+
313
+ export const getStorageFileUrl = query({
314
+ args: {
315
+ storageId: v.id("_storage"),
316
+ },
317
+ returns: v.union(v.null(), v.string()),
318
+ handler: async (ctx, args) => {
319
+ return await ctx.storage.getUrl(args.storageId);
320
+ },
321
+ });
322
+
323
+ export const attachMessageMetadata = mutation({
324
+ args: {
325
+ messageId: v.id("messageQueue"),
326
+ metadata: v.record(v.string(), v.string()),
327
+ },
328
+ returns: v.boolean(),
329
+ handler: async (ctx, args) => {
330
+ const message = await ctx.db.get(args.messageId);
331
+ if (!message) return false;
332
+ await ctx.db.patch(message._id, {
333
+ payload: {
334
+ ...message.payload,
335
+ metadata: {
336
+ ...(message.payload.metadata ?? {}),
337
+ ...args.metadata,
338
+ },
339
+ },
340
+ });
341
+ return true;
342
+ },
343
+ });
344
+
345
+ export const claimNextJob = mutation({
346
+ args: {
347
+ workerId: v.string(),
348
+ nowMs: v.optional(v.number()),
349
+ },
350
+ returns: v.union(v.null(), claimedJobValidator),
351
+ handler: async (ctx, args) => {
352
+ const nowMs = args.nowMs ?? Date.now();
353
+ const candidates = await ctx.db
354
+ .query("messageQueue")
355
+ .withIndex("by_status_and_scheduledFor", (q) =>
356
+ q.eq("status", "queued").lte("scheduledFor", nowMs),
357
+ )
358
+ .take(DEFAULT_CONFIG.queue.claimBatchSize);
359
+
360
+ candidates.sort((a, b) => {
361
+ if (a.priority !== b.priority) return b.priority - a.priority;
362
+ if (a.scheduledFor !== b.scheduledFor) return a.scheduledFor - b.scheduledFor;
363
+ return a._creationTime - b._creationTime;
364
+ });
365
+
366
+ for (const candidate of candidates) {
367
+ const conversation = await ctx.db
368
+ .query("conversations")
369
+ .withIndex("by_conversationId", (q) =>
370
+ q.eq("conversationId", candidate.conversationId),
371
+ )
372
+ .unique();
373
+ if (!conversation) continue;
374
+
375
+ const lock = conversation.processingLock;
376
+ if (lock && lock.leaseExpiresAt > nowMs) continue;
377
+
378
+ const leaseId = `${nowMs}-${Math.random().toString(36).slice(2, 10)}`;
379
+ const leaseExpiresAt = nowMs + DEFAULT_CONFIG.lease.leaseMs;
380
+
381
+ await ctx.db.patch(candidate._id, {
382
+ status: "processing",
383
+ claimedBy: args.workerId,
384
+ leaseId,
385
+ leaseExpiresAt,
386
+ });
387
+
388
+ await ctx.db.patch(conversation._id, {
389
+ processingLock: {
390
+ leaseId,
391
+ workerId: args.workerId,
392
+ leaseExpiresAt,
393
+ heartbeatAt: nowMs,
394
+ claimedMessageId: candidate._id,
395
+ },
396
+ });
397
+
398
+ const worker = await ctx.db
399
+ .query("workers")
400
+ .withIndex("by_workerId", (q) => q.eq("workerId", args.workerId))
401
+ .unique();
402
+
403
+ if (!worker) {
404
+ await ctx.db.insert("workers", {
405
+ workerId: args.workerId,
406
+ provider: "fly",
407
+ status: "active",
408
+ load: 1,
409
+ heartbeatAt: nowMs,
410
+ lastClaimAt: nowMs,
411
+ scheduledShutdownAt: undefined,
412
+ capabilities: [],
413
+ });
414
+ } else {
415
+ await ctx.db.patch(worker._id, {
416
+ status: "active",
417
+ load: worker.load + 1,
418
+ heartbeatAt: nowMs,
419
+ lastClaimAt: nowMs,
420
+ scheduledShutdownAt: undefined,
421
+ });
422
+ }
423
+
424
+ return {
425
+ messageId: candidate._id,
426
+ conversationId: candidate.conversationId,
427
+ agentKey: candidate.agentKey,
428
+ leaseId,
429
+ leaseExpiresAt,
430
+ payload: candidate.payload,
431
+ };
432
+ }
433
+ return null;
434
+ },
435
+ });
436
+
437
+ export const heartbeatJob = mutation({
438
+ args: {
439
+ workerId: v.string(),
440
+ messageId: v.id("messageQueue"),
441
+ leaseId: v.string(),
442
+ nowMs: v.optional(v.number()),
443
+ },
444
+ returns: v.boolean(),
445
+ handler: async (ctx, args) => {
446
+ const nowMs = args.nowMs ?? Date.now();
447
+ const message = await ctx.db.get(args.messageId);
448
+ if (
449
+ !message ||
450
+ message.status !== "processing" ||
451
+ message.leaseId !== args.leaseId ||
452
+ message.claimedBy !== args.workerId
453
+ ) {
454
+ return false;
455
+ }
456
+
457
+ const leaseExpiresAt = nowMs + DEFAULT_CONFIG.lease.leaseMs;
458
+ await ctx.db.patch(message._id, {
459
+ leaseExpiresAt,
460
+ });
461
+
462
+ const conversation = await ctx.db
463
+ .query("conversations")
464
+ .withIndex("by_conversationId", (q) =>
465
+ q.eq("conversationId", message.conversationId),
466
+ )
467
+ .unique();
468
+ if (
469
+ conversation?.processingLock &&
470
+ conversation.processingLock.leaseId === args.leaseId
471
+ ) {
472
+ await ctx.db.patch(conversation._id, {
473
+ processingLock: {
474
+ ...conversation.processingLock,
475
+ leaseExpiresAt,
476
+ heartbeatAt: nowMs,
477
+ },
478
+ });
479
+ }
480
+
481
+ const worker = await ctx.db
482
+ .query("workers")
483
+ .withIndex("by_workerId", (q) => q.eq("workerId", args.workerId))
484
+ .unique();
485
+ if (worker) {
486
+ await ctx.db.patch(worker._id, { heartbeatAt: nowMs });
487
+ }
488
+
489
+ return true;
490
+ },
491
+ });
492
+
493
+ export const completeJob = mutation({
494
+ args: {
495
+ workerId: v.string(),
496
+ messageId: v.id("messageQueue"),
497
+ leaseId: v.string(),
498
+ nowMs: v.optional(v.number()),
499
+ },
500
+ returns: v.boolean(),
501
+ handler: async (ctx, args) => {
502
+ const nowMs = args.nowMs ?? Date.now();
503
+ const message = await ctx.db.get(args.messageId);
504
+ if (
505
+ !message ||
506
+ message.status !== "processing" ||
507
+ message.leaseId !== args.leaseId ||
508
+ message.claimedBy !== args.workerId
509
+ ) {
510
+ return false;
511
+ }
512
+
513
+ await ctx.db.patch(message._id, {
514
+ status: "done",
515
+ claimedBy: undefined,
516
+ leaseId: undefined,
517
+ leaseExpiresAt: undefined,
518
+ lastError: undefined,
519
+ nextRetryAt: undefined,
520
+ });
521
+
522
+ const conversation = await ctx.db
523
+ .query("conversations")
524
+ .withIndex("by_conversationId", (q) =>
525
+ q.eq("conversationId", message.conversationId),
526
+ )
527
+ .unique();
528
+ if (
529
+ conversation?.processingLock &&
530
+ conversation.processingLock.claimedMessageId === message._id
531
+ ) {
532
+ await ctx.db.patch(conversation._id, { processingLock: undefined });
533
+ }
534
+
535
+ const worker = await ctx.db
536
+ .query("workers")
537
+ .withIndex("by_workerId", (q) => q.eq("workerId", args.workerId))
538
+ .unique();
539
+ if (worker) {
540
+ const nextLoad = Math.max(0, worker.load - 1);
541
+ await ctx.db.patch(worker._id, {
542
+ load: nextLoad,
543
+ heartbeatAt: nowMs,
544
+ scheduledShutdownAt:
545
+ nextLoad === 0
546
+ ? nowMs + DEFAULT_CONFIG.scaling.idleTimeoutMs
547
+ : undefined,
548
+ });
549
+ }
550
+ return true;
551
+ },
552
+ });
553
+
554
+ export const failJob = mutation({
555
+ args: {
556
+ workerId: v.string(),
557
+ messageId: v.id("messageQueue"),
558
+ leaseId: v.string(),
559
+ errorMessage: v.string(),
560
+ nowMs: v.optional(v.number()),
561
+ },
562
+ returns: v.object({
563
+ requeued: v.boolean(),
564
+ deadLettered: v.boolean(),
565
+ nextScheduledFor: v.union(v.null(), v.number()),
566
+ }),
567
+ handler: async (ctx, args) => {
568
+ const nowMs = args.nowMs ?? Date.now();
569
+ const message = await ctx.db.get(args.messageId);
570
+ if (
571
+ !message ||
572
+ message.status !== "processing" ||
573
+ message.leaseId !== args.leaseId ||
574
+ message.claimedBy !== args.workerId
575
+ ) {
576
+ return { requeued: false, deadLettered: false, nextScheduledFor: null };
577
+ }
578
+
579
+ const attempts = message.attempts + 1;
580
+ const reachedMaxAttempts = attempts >= message.maxAttempts;
581
+ let nextScheduledFor: number | null = null;
582
+
583
+ if (reachedMaxAttempts) {
584
+ await ctx.db.patch(message._id, {
585
+ status: "dead_letter",
586
+ attempts,
587
+ deadLetteredAt: nowMs,
588
+ lastError: args.errorMessage,
589
+ claimedBy: undefined,
590
+ leaseId: undefined,
591
+ leaseExpiresAt: undefined,
592
+ });
593
+ } else {
594
+ const retryDelayMs = computeRetryDelayMs(attempts, DEFAULT_CONFIG.retry, nowMs);
595
+ nextScheduledFor = nowMs + retryDelayMs;
596
+ await ctx.db.patch(message._id, {
597
+ status: "queued",
598
+ attempts,
599
+ scheduledFor: nextScheduledFor,
600
+ nextRetryAt: nextScheduledFor,
601
+ lastError: args.errorMessage,
602
+ claimedBy: undefined,
603
+ leaseId: undefined,
604
+ leaseExpiresAt: undefined,
605
+ });
606
+ }
607
+
608
+ const conversation = await ctx.db
609
+ .query("conversations")
610
+ .withIndex("by_conversationId", (q) =>
611
+ q.eq("conversationId", message.conversationId),
612
+ )
613
+ .unique();
614
+ if (
615
+ conversation?.processingLock &&
616
+ conversation.processingLock.claimedMessageId === message._id
617
+ ) {
618
+ await ctx.db.patch(conversation._id, { processingLock: undefined });
619
+ }
620
+
621
+ const worker = await ctx.db
622
+ .query("workers")
623
+ .withIndex("by_workerId", (q) => q.eq("workerId", args.workerId))
624
+ .unique();
625
+ if (worker) {
626
+ const nextLoad = Math.max(0, worker.load - 1);
627
+ await ctx.db.patch(worker._id, {
628
+ load: nextLoad,
629
+ heartbeatAt: nowMs,
630
+ scheduledShutdownAt:
631
+ nextLoad === 0
632
+ ? nowMs + DEFAULT_CONFIG.scaling.idleTimeoutMs
633
+ : undefined,
634
+ });
635
+ }
636
+
637
+ return {
638
+ requeued: !reachedMaxAttempts,
639
+ deadLettered: reachedMaxAttempts,
640
+ nextScheduledFor,
641
+ };
642
+ },
643
+ });
644
+
645
+ export const releaseExpiredLeases = internalMutation({
646
+ args: {
647
+ nowMs: v.optional(v.number()),
648
+ limit: v.optional(v.number()),
649
+ },
650
+ returns: v.object({
651
+ requeued: v.number(),
652
+ unlocked: v.number(),
653
+ }),
654
+ handler: async (ctx, args) => {
655
+ const nowMs = args.nowMs ?? Date.now();
656
+ const limit = args.limit ?? 100;
657
+
658
+ const stuck = await ctx.db
659
+ .query("messageQueue")
660
+ .withIndex("by_status_and_leaseExpiresAt", (q) =>
661
+ q.eq("status", "processing").lte("leaseExpiresAt", nowMs),
662
+ )
663
+ .take(limit);
664
+
665
+ let requeued = 0;
666
+ let unlocked = 0;
667
+ for (const message of stuck) {
668
+ await ctx.db.patch(message._id, {
669
+ status: "queued",
670
+ scheduledFor: nowMs,
671
+ claimedBy: undefined,
672
+ leaseId: undefined,
673
+ leaseExpiresAt: undefined,
674
+ lastError: "Lease expired while processing",
675
+ });
676
+ requeued += 1;
677
+
678
+ const conversation = await ctx.db
679
+ .query("conversations")
680
+ .withIndex("by_conversationId", (q) =>
681
+ q.eq("conversationId", message.conversationId),
682
+ )
683
+ .unique();
684
+ if (
685
+ conversation?.processingLock &&
686
+ conversation.processingLock.claimedMessageId === message._id
687
+ ) {
688
+ await ctx.db.patch(conversation._id, { processingLock: undefined });
689
+ unlocked += 1;
690
+ }
691
+ }
692
+
693
+ return { requeued, unlocked };
694
+ },
695
+ });
696
+
697
+ export const prepareHydrationSnapshot = internalMutation({
698
+ args: {
699
+ workspaceId: v.string(),
700
+ agentKey: v.string(),
701
+ nowMs: v.optional(v.number()),
702
+ },
703
+ returns: v.object({
704
+ snapshotId: v.id("hydrationSnapshots"),
705
+ snapshotKey: v.string(),
706
+ reused: v.boolean(),
707
+ expiresAt: v.number(),
708
+ }),
709
+ handler: async (ctx, args) => {
710
+ const nowMs = args.nowMs ?? Date.now();
711
+ const snapshotKey = `${args.workspaceId}:${args.agentKey}`;
712
+ const agentProfile = await ctx.db
713
+ .query("agentProfiles")
714
+ .withIndex("by_agentKey", (q) => q.eq("agentKey", args.agentKey))
715
+ .unique();
716
+ if (!agentProfile) throw new Error(`Agent profile '${args.agentKey}' not found`);
717
+
718
+ const docs = await ctx.db
719
+ .query("workspaceDocuments")
720
+ .withIndex("by_workspaceId_and_updatedAt", (q) => q.eq("workspaceId", args.workspaceId))
721
+ .order("desc")
722
+ .take(200);
723
+
724
+ const skills = (
725
+ await ctx.db
726
+ .query("agentSkills")
727
+ .withIndex("by_agentKey_and_enabled", (q) =>
728
+ q.eq("agentKey", args.agentKey).eq("enabled", true),
729
+ )
730
+ .collect()
731
+ ).filter((skill) => skill.workspaceId === args.workspaceId);
732
+ const includeSkillAssets =
733
+ DEFAULT_CONFIG.hydration.skillAssetScanMode === "manifest_and_assets";
734
+ const assetsBySkill = new Map<
735
+ string,
736
+ Array<{
737
+ assetPath: string;
738
+ assetType: "script" | "config" | "venv" | "other";
739
+ contentHash: string;
740
+ sizeBytes: number;
741
+ }>
742
+ >();
743
+ if (includeSkillAssets) {
744
+ for (const skill of skills) {
745
+ const skillAssets = await ctx.db
746
+ .query("skillAssets")
747
+ .withIndex("by_workspaceId_and_skillKey", (q) =>
748
+ q.eq("workspaceId", args.workspaceId).eq("skillKey", skill.skillKey),
749
+ )
750
+ .collect();
751
+ assetsBySkill.set(
752
+ skill.skillKey,
753
+ skillAssets.map((asset) => ({
754
+ assetPath: asset.assetPath,
755
+ assetType: asset.assetType,
756
+ contentHash: asset.contentHash,
757
+ sizeBytes: asset.sizeBytes,
758
+ })),
759
+ );
760
+ }
761
+ }
762
+
763
+ const secretFingerprints: Array<string> = [];
764
+ for (const ref of agentProfile.secretsRef) {
765
+ const activeSecret = await ctx.db
766
+ .query("secrets")
767
+ .withIndex("by_secretRef_and_active", (q) =>
768
+ q.eq("secretRef", ref).eq("active", true),
769
+ )
770
+ .unique();
771
+ if (activeSecret) {
772
+ secretFingerprints.push(`${activeSecret.secretRef}:${activeSecret.version}`);
773
+ }
774
+ }
775
+
776
+ const sourceFingerprint = [
777
+ agentProfile.version,
778
+ ...docs.map((doc) => `${doc.path}:${doc.contentHash}:${doc.version}`),
779
+ ...skills.map((skill) => `${skill.skillKey}:${skill.updatedAt}`),
780
+ ...[...assetsBySkill.entries()].flatMap(([skillKey, assets]) =>
781
+ assets.map((asset) => `${skillKey}:${asset.assetPath}:${asset.contentHash}`),
782
+ ),
783
+ ...secretFingerprints,
784
+ ].join("|");
785
+
786
+ const existing = await ctx.db
787
+ .query("hydrationSnapshots")
788
+ .withIndex("by_snapshotKey", (q) => q.eq("snapshotKey", snapshotKey))
789
+ .first();
790
+
791
+ const expiresAt = nowMs + DEFAULT_CONFIG.hydration.snapshotTtlMs;
792
+ if (
793
+ existing &&
794
+ existing.sourceFingerprint === sourceFingerprint &&
795
+ existing.expiresAt > nowMs &&
796
+ existing.status === "ready"
797
+ ) {
798
+ return {
799
+ snapshotId: existing._id,
800
+ snapshotKey,
801
+ reused: true,
802
+ expiresAt: existing.expiresAt,
803
+ };
804
+ }
805
+
806
+ if (existing) {
807
+ await ctx.db.patch(existing._id, {
808
+ status: "stale",
809
+ expiresAt: nowMs,
810
+ });
811
+ }
812
+
813
+ const promptSections = docs
814
+ .filter((doc) => doc.isActive)
815
+ .sort((a, b) => a.path.localeCompare(b.path))
816
+ .map((doc) => ({
817
+ section: `${doc.docType}:${doc.path}`,
818
+ content: doc.content,
819
+ }));
820
+
821
+ const memoryWindow = docs
822
+ .filter(
823
+ (doc) =>
824
+ doc.docType === "memory_daily" || doc.docType === "memory_longterm",
825
+ )
826
+ .slice(0, 10)
827
+ .map((doc) => ({
828
+ path: doc.path,
829
+ excerpt: doc.content.slice(0, 2_000),
830
+ }));
831
+
832
+ const snapshotId = await ctx.db.insert("hydrationSnapshots", {
833
+ workspaceId: args.workspaceId,
834
+ agentKey: args.agentKey,
835
+ snapshotKey,
836
+ snapshotVersion: (existing?.snapshotVersion ?? 0) + 1,
837
+ sourceFingerprint,
838
+ compiledPromptStack: promptSections,
839
+ skillsBundle: skills.map((skill) => ({
840
+ skillKey: skill.skillKey,
841
+ manifestMd: skill.manifestMd,
842
+ assets: assetsBySkill.get(skill.skillKey),
843
+ })),
844
+ memoryWindow,
845
+ tokenEstimate: estimateTokens(promptSections, memoryWindow),
846
+ builtAt: nowMs,
847
+ expiresAt,
848
+ status: "ready",
849
+ });
850
+
851
+ return {
852
+ snapshotId,
853
+ snapshotKey,
854
+ reused: false,
855
+ expiresAt,
856
+ };
857
+ },
858
+ });
859
+
860
+ export const getHydrationBundleForClaimedJob = query({
861
+ args: {
862
+ messageId: v.id("messageQueue"),
863
+ workspaceId: v.string(),
864
+ },
865
+ returns: v.union(
866
+ v.null(),
867
+ v.object({
868
+ messageId: v.id("messageQueue"),
869
+ conversationId: v.string(),
870
+ agentKey: v.string(),
871
+ payload: queuePayloadValidator,
872
+ snapshot: v.union(
873
+ v.null(),
874
+ v.object({
875
+ snapshotId: v.id("hydrationSnapshots"),
876
+ snapshotKey: v.string(),
877
+ compiledPromptStack: v.array(
878
+ v.object({
879
+ section: v.string(),
880
+ content: v.string(),
881
+ }),
882
+ ),
883
+ skillsBundle: v.array(
884
+ v.object({
885
+ skillKey: v.string(),
886
+ manifestMd: v.string(),
887
+ assets: v.optional(
888
+ v.array(
889
+ v.object({
890
+ assetPath: v.string(),
891
+ assetType: v.union(
892
+ v.literal("script"),
893
+ v.literal("config"),
894
+ v.literal("venv"),
895
+ v.literal("other"),
896
+ ),
897
+ contentHash: v.string(),
898
+ sizeBytes: v.number(),
899
+ }),
900
+ ),
901
+ ),
902
+ }),
903
+ ),
904
+ memoryWindow: v.array(
905
+ v.object({
906
+ path: v.string(),
907
+ excerpt: v.string(),
908
+ }),
909
+ ),
910
+ }),
911
+ ),
912
+ conversationState: v.object({
913
+ contextHistory: v.array(
914
+ v.object({
915
+ role: v.union(
916
+ v.literal("system"),
917
+ v.literal("user"),
918
+ v.literal("assistant"),
919
+ v.literal("tool"),
920
+ ),
921
+ content: v.string(),
922
+ at: v.number(),
923
+ }),
924
+ ),
925
+ pendingToolCalls: v.array(
926
+ v.object({
927
+ toolName: v.string(),
928
+ callId: v.string(),
929
+ status: v.union(
930
+ v.literal("pending"),
931
+ v.literal("running"),
932
+ v.literal("done"),
933
+ v.literal("failed"),
934
+ ),
935
+ }),
936
+ ),
937
+ }),
938
+ telegramBotToken: v.union(v.null(), v.string()),
939
+ }),
940
+ ),
941
+ handler: async (ctx, args) => {
942
+ const message = await ctx.db.get(args.messageId);
943
+ if (!message || message.status !== "processing") return null;
944
+
945
+ const conversation = await ctx.db
946
+ .query("conversations")
947
+ .withIndex("by_conversationId", (q) =>
948
+ q.eq("conversationId", message.conversationId),
949
+ )
950
+ .unique();
951
+ const profile = await ctx.db
952
+ .query("agentProfiles")
953
+ .withIndex("by_agentKey", (q) => q.eq("agentKey", message.agentKey))
954
+ .unique();
955
+ if (!conversation || !profile) return null;
956
+
957
+ const snapshotKey = `${args.workspaceId}:${message.agentKey}`;
958
+ const snapshot = await ctx.db
959
+ .query("hydrationSnapshots")
960
+ .withIndex("by_snapshotKey", (q) => q.eq("snapshotKey", snapshotKey))
961
+ .first();
962
+ const conversationCache = await ctx.db
963
+ .query("conversationHydrationCache")
964
+ .withIndex("by_conversationId", (q) => q.eq("conversationId", message.conversationId))
965
+ .first();
966
+
967
+ let telegramBotToken: string | null = null;
968
+ const telegramSecretRefs = profile.secretsRef.filter(
969
+ (ref) => ref === "telegram.botToken" || ref.startsWith("telegram.botToken."),
970
+ );
971
+ for (const telegramSecretRef of telegramSecretRefs) {
972
+ const activeSecret = await ctx.db
973
+ .query("secrets")
974
+ .withIndex("by_secretRef_and_active", (q) =>
975
+ q.eq("secretRef", telegramSecretRef).eq("active", true),
976
+ )
977
+ .unique();
978
+ if (activeSecret) {
979
+ telegramBotToken = decryptSecretValue(activeSecret.encryptedValue, activeSecret.algorithm);
980
+ break;
981
+ }
982
+ }
983
+
984
+ const contextHistory =
985
+ conversationCache && conversationCache.snapshotKey === snapshotKey
986
+ ? conversationCache.deltaContext
987
+ : conversation.contextHistory;
988
+
989
+ return {
990
+ messageId: message._id,
991
+ conversationId: message.conversationId,
992
+ agentKey: message.agentKey,
993
+ payload: message.payload,
994
+ snapshot: snapshot
995
+ ? {
996
+ snapshotId: snapshot._id,
997
+ snapshotKey: snapshot.snapshotKey,
998
+ compiledPromptStack: snapshot.compiledPromptStack,
999
+ skillsBundle: snapshot.skillsBundle,
1000
+ memoryWindow: snapshot.memoryWindow,
1001
+ }
1002
+ : null,
1003
+ conversationState: {
1004
+ contextHistory,
1005
+ pendingToolCalls: conversation.pendingToolCalls,
1006
+ },
1007
+ telegramBotToken,
1008
+ };
1009
+ },
1010
+ });
1011
+
1012
+ export const getQueueStats = query({
1013
+ args: {
1014
+ nowMs: v.optional(v.number()),
1015
+ },
1016
+ returns: v.object({
1017
+ queuedReady: v.number(),
1018
+ processing: v.number(),
1019
+ deadLetter: v.number(),
1020
+ }),
1021
+ handler: async (ctx, args) => {
1022
+ const nowMs = args.nowMs ?? Date.now();
1023
+ const queued = await ctx.db
1024
+ .query("messageQueue")
1025
+ .withIndex("by_status_and_scheduledFor", (q) => q.eq("status", "queued"))
1026
+ .collect();
1027
+
1028
+ const processing = await ctx.db
1029
+ .query("messageQueue")
1030
+ .withIndex("by_status_and_scheduledFor", (q) => q.eq("status", "processing"))
1031
+ .collect();
1032
+
1033
+ const deadLetter = await ctx.db
1034
+ .query("messageQueue")
1035
+ .withIndex("by_status_and_scheduledFor", (q) => q.eq("status", "dead_letter"))
1036
+ .collect();
1037
+
1038
+ return {
1039
+ queuedReady: queued.filter((job) => job.scheduledFor <= nowMs).length,
1040
+ processing: processing.length,
1041
+ deadLetter: deadLetter.length,
1042
+ };
1043
+ },
1044
+ });
1045
+
1046
+ export const hasQueuedJobsForConversation = query({
1047
+ args: {
1048
+ conversationId: v.string(),
1049
+ },
1050
+ returns: v.boolean(),
1051
+ handler: async (ctx, args) => {
1052
+ const queuedJob = await ctx.db
1053
+ .query("messageQueue")
1054
+ .withIndex("by_conversationId_and_status", (q) =>
1055
+ q.eq("conversationId", args.conversationId).eq("status", "queued"),
1056
+ )
1057
+ .first();
1058
+
1059
+ return queuedJob !== null;
1060
+ },
1061
+ });
1062
+
1063
+ export const getReadyConversationCountForScheduler = internalQuery({
1064
+ args: {
1065
+ nowMs: v.optional(v.number()),
1066
+ limit: v.optional(v.number()),
1067
+ },
1068
+ returns: v.number(),
1069
+ handler: async (ctx, args) => {
1070
+ const nowMs = args.nowMs ?? Date.now();
1071
+ const limit = Math.max(1, args.limit ?? 1000);
1072
+ const queuedJobs = await ctx.db
1073
+ .query("messageQueue")
1074
+ .withIndex("by_status_and_scheduledFor", (q) =>
1075
+ q.eq("status", "queued").lte("scheduledFor", nowMs),
1076
+ )
1077
+ .take(limit);
1078
+ const conversationIds = [...new Set(queuedJobs.map((job) => job.conversationId))];
1079
+ let readyConversations = 0;
1080
+ for (const conversationId of conversationIds) {
1081
+ const conversation = await ctx.db
1082
+ .query("conversations")
1083
+ .withIndex("by_conversationId", (q) => q.eq("conversationId", conversationId))
1084
+ .unique();
1085
+ const lock = conversation?.processingLock;
1086
+ if (!lock || lock.leaseExpiresAt <= nowMs) {
1087
+ readyConversations += 1;
1088
+ }
1089
+ }
1090
+ return readyConversations;
1091
+ },
1092
+ });
1093
+
1094
+ export const listJobsByStatus = query({
1095
+ args: {
1096
+ status: queueStatusValidator,
1097
+ limit: v.optional(v.number()),
1098
+ },
1099
+ returns: v.array(
1100
+ v.object({
1101
+ _id: v.id("messageQueue"),
1102
+ _creationTime: v.number(),
1103
+ conversationId: v.string(),
1104
+ agentKey: v.string(),
1105
+ status: queueStatusValidator,
1106
+ priority: v.number(),
1107
+ scheduledFor: v.number(),
1108
+ attempts: v.number(),
1109
+ maxAttempts: v.number(),
1110
+ lastError: v.optional(v.string()),
1111
+ }),
1112
+ ),
1113
+ handler: async (ctx, args) => {
1114
+ const jobs = await ctx.db
1115
+ .query("messageQueue")
1116
+ .withIndex("by_status_and_scheduledFor", (q) => q.eq("status", args.status))
1117
+ .order("asc")
1118
+ .take(args.limit ?? 100);
1119
+ return jobs.map((job) => ({
1120
+ _id: job._id,
1121
+ _creationTime: job._creationTime,
1122
+ conversationId: job.conversationId,
1123
+ agentKey: job.agentKey,
1124
+ status: job.status,
1125
+ priority: job.priority,
1126
+ scheduledFor: job.scheduledFor,
1127
+ attempts: job.attempts,
1128
+ maxAttempts: job.maxAttempts,
1129
+ lastError: job.lastError,
1130
+ }));
1131
+ },
1132
+ });
1133
+
1134
+ export const upsertWorkerState = internalMutation({
1135
+ args: {
1136
+ workerId: v.string(),
1137
+ provider: v.string(),
1138
+ status: v.union(
1139
+ v.literal("active"),
1140
+ v.literal("stopped"),
1141
+ ),
1142
+ load: v.number(),
1143
+ nowMs: v.optional(v.number()),
1144
+ scheduledShutdownAt: v.optional(v.number()),
1145
+ machineId: v.optional(v.string()),
1146
+ appName: v.optional(v.string()),
1147
+ region: v.optional(v.string()),
1148
+ },
1149
+ returns: v.null(),
1150
+ handler: async (ctx, args) => {
1151
+ const nowMs = args.nowMs ?? Date.now();
1152
+ const worker = await ctx.db
1153
+ .query("workers")
1154
+ .withIndex("by_workerId", (q) => q.eq("workerId", args.workerId))
1155
+ .unique();
1156
+ if (!worker) {
1157
+ await ctx.db.insert("workers", {
1158
+ workerId: args.workerId,
1159
+ provider: args.provider,
1160
+ status: args.status,
1161
+ load: args.load,
1162
+ heartbeatAt: nowMs,
1163
+ scheduledShutdownAt: args.scheduledShutdownAt,
1164
+ machineRef:
1165
+ args.machineId && args.appName
1166
+ ? {
1167
+ appName: args.appName,
1168
+ machineId: args.machineId,
1169
+ region: args.region,
1170
+ }
1171
+ : undefined,
1172
+ capabilities: [],
1173
+ });
1174
+ return null;
1175
+ }
1176
+
1177
+ await ctx.db.patch(worker._id, {
1178
+ status: args.status,
1179
+ load: args.load,
1180
+ heartbeatAt: nowMs,
1181
+ scheduledShutdownAt: args.scheduledShutdownAt ?? worker.scheduledShutdownAt,
1182
+ machineRef:
1183
+ args.machineId && args.appName
1184
+ ? {
1185
+ appName: args.appName,
1186
+ machineId: args.machineId,
1187
+ region: args.region,
1188
+ }
1189
+ : worker.machineRef,
1190
+ });
1191
+ return null;
1192
+ },
1193
+ });
1194
+
1195
+ export const getWorkerControlState = query({
1196
+ args: {
1197
+ workerId: v.string(),
1198
+ },
1199
+ returns: v.object({
1200
+ shouldStop: v.boolean(),
1201
+ }),
1202
+ handler: async (ctx, args) => {
1203
+ const worker = await ctx.db
1204
+ .query("workers")
1205
+ .withIndex("by_workerId", (q) => q.eq("workerId", args.workerId))
1206
+ .unique();
1207
+ return {
1208
+ shouldStop: !worker || worker.status === "stopped",
1209
+ };
1210
+ },
1211
+ });
1212
+
1213
+ export const prepareDataSnapshotUpload = mutation({
1214
+ args: {
1215
+ workerId: v.string(),
1216
+ workspaceId: v.string(),
1217
+ agentKey: v.string(),
1218
+ conversationId: v.optional(v.string()),
1219
+ reason: snapshotReasonValidator,
1220
+ nowMs: v.optional(v.number()),
1221
+ },
1222
+ returns: v.object({
1223
+ snapshotId: v.id("dataSnapshots"),
1224
+ uploadUrl: v.string(),
1225
+ expiresAt: v.number(),
1226
+ }),
1227
+ handler: async (ctx, args) => {
1228
+ const nowMs = args.nowMs ?? Date.now();
1229
+ const expiresAt = nowMs + DATA_SNAPSHOT_RETENTION_MS;
1230
+ const snapshotId = await ctx.db.insert("dataSnapshots", {
1231
+ workspaceId: args.workspaceId,
1232
+ agentKey: args.agentKey,
1233
+ workerId: args.workerId,
1234
+ conversationId: args.conversationId,
1235
+ reason: args.reason,
1236
+ formatVersion: 1,
1237
+ status: "uploading",
1238
+ createdAt: nowMs,
1239
+ expiresAt,
1240
+ });
1241
+ const uploadUrl = await ctx.storage.generateUploadUrl();
1242
+ return { snapshotId, uploadUrl, expiresAt };
1243
+ },
1244
+ });
1245
+
1246
+ export const finalizeDataSnapshotUpload = mutation({
1247
+ args: {
1248
+ workerId: v.string(),
1249
+ snapshotId: v.id("dataSnapshots"),
1250
+ storageId: v.id("_storage"),
1251
+ sha256: v.string(),
1252
+ sizeBytes: v.number(),
1253
+ nowMs: v.optional(v.number()),
1254
+ },
1255
+ returns: v.boolean(),
1256
+ handler: async (ctx, args) => {
1257
+ const nowMs = args.nowMs ?? Date.now();
1258
+ const snapshot = await ctx.db.get(args.snapshotId);
1259
+ if (!snapshot || snapshot.workerId !== args.workerId) return false;
1260
+ await ctx.db.patch(snapshot._id, {
1261
+ archiveFileId: args.storageId,
1262
+ sha256: args.sha256,
1263
+ sizeBytes: args.sizeBytes,
1264
+ status: "ready",
1265
+ completedAt: nowMs,
1266
+ });
1267
+ const worker = await ctx.db
1268
+ .query("workers")
1269
+ .withIndex("by_workerId", (q) => q.eq("workerId", args.workerId))
1270
+ .unique();
1271
+ if (worker) {
1272
+ await ctx.db.patch(worker._id, {
1273
+ lastSnapshotId: snapshot._id,
1274
+ });
1275
+ }
1276
+ return true;
1277
+ },
1278
+ });
1279
+
1280
+ export const failDataSnapshotUpload = mutation({
1281
+ args: {
1282
+ workerId: v.string(),
1283
+ snapshotId: v.id("dataSnapshots"),
1284
+ error: v.string(),
1285
+ nowMs: v.optional(v.number()),
1286
+ },
1287
+ returns: v.boolean(),
1288
+ handler: async (ctx, args) => {
1289
+ const nowMs = args.nowMs ?? Date.now();
1290
+ const snapshot = await ctx.db.get(args.snapshotId);
1291
+ if (!snapshot || snapshot.workerId !== args.workerId) return false;
1292
+ await ctx.db.patch(snapshot._id, {
1293
+ status: "failed",
1294
+ error: args.error,
1295
+ completedAt: nowMs,
1296
+ });
1297
+ return true;
1298
+ },
1299
+ });
1300
+
1301
+ export const getLatestDataSnapshotForRestore = query({
1302
+ args: {
1303
+ workspaceId: v.string(),
1304
+ agentKey: v.string(),
1305
+ conversationId: v.optional(v.string()),
1306
+ nowMs: v.optional(v.number()),
1307
+ },
1308
+ returns: v.union(
1309
+ v.null(),
1310
+ v.object({
1311
+ snapshotId: v.id("dataSnapshots"),
1312
+ downloadUrl: v.string(),
1313
+ sha256: v.union(v.null(), v.string()),
1314
+ sizeBytes: v.union(v.null(), v.number()),
1315
+ createdAt: v.number(),
1316
+ }),
1317
+ ),
1318
+ handler: async (ctx, args) => {
1319
+ const nowMs = args.nowMs ?? Date.now();
1320
+ const candidates = await ctx.db
1321
+ .query("dataSnapshots")
1322
+ .withIndex("by_workspaceId_and_agentKey_and_createdAt", (q) =>
1323
+ q.eq("workspaceId", args.workspaceId).eq("agentKey", args.agentKey),
1324
+ )
1325
+ .order("desc")
1326
+ .take(50);
1327
+ const ready = candidates.filter(
1328
+ (snapshot) =>
1329
+ snapshot.status === "ready" &&
1330
+ snapshot.archiveFileId !== undefined &&
1331
+ snapshot.expiresAt > nowMs,
1332
+ );
1333
+ const preferred =
1334
+ (args.conversationId
1335
+ ? ready.find((snapshot) => snapshot.conversationId === args.conversationId)
1336
+ : undefined) ?? ready[0];
1337
+ if (!preferred || !preferred.archiveFileId) return null;
1338
+ const downloadUrl = await ctx.storage.getUrl(preferred.archiveFileId);
1339
+ if (!downloadUrl) return null;
1340
+ return {
1341
+ snapshotId: preferred._id,
1342
+ downloadUrl,
1343
+ sha256: preferred.sha256 ?? null,
1344
+ sizeBytes: preferred.sizeBytes ?? null,
1345
+ createdAt: preferred.createdAt,
1346
+ };
1347
+ },
1348
+ });
1349
+
1350
+ export const listWorkersForScheduler = internalQuery({
1351
+ args: {},
1352
+ returns: v.array(
1353
+ v.object({
1354
+ workerId: v.string(),
1355
+ status: v.union(
1356
+ v.literal("active"),
1357
+ v.literal("stopped"),
1358
+ ),
1359
+ load: v.number(),
1360
+ heartbeatAt: v.number(),
1361
+ lastClaimAt: v.union(v.null(), v.number()),
1362
+ scheduledShutdownAt: v.union(v.null(), v.number()),
1363
+ machineId: v.union(v.null(), v.string()),
1364
+ appName: v.union(v.null(), v.string()),
1365
+ region: v.union(v.null(), v.string()),
1366
+ }),
1367
+ ),
1368
+ handler: async (ctx) => {
1369
+ const rows = await ctx.db.query("workers").collect();
1370
+ return rows.map((worker) => ({
1371
+ workerId: worker.workerId,
1372
+ status: worker.status,
1373
+ load: worker.load,
1374
+ heartbeatAt: worker.heartbeatAt,
1375
+ lastClaimAt: worker.lastClaimAt ?? null,
1376
+ scheduledShutdownAt: worker.scheduledShutdownAt ?? null,
1377
+ machineId: worker.machineRef?.machineId ?? null,
1378
+ appName: worker.machineRef?.appName ?? null,
1379
+ region: worker.machineRef?.region ?? null,
1380
+ }));
1381
+ },
1382
+ });
1383
+
1384
+ export const expireOldDataSnapshots = internalMutation({
1385
+ args: {
1386
+ nowMs: v.optional(v.number()),
1387
+ limit: v.optional(v.number()),
1388
+ },
1389
+ returns: v.number(),
1390
+ handler: async (ctx, args) => {
1391
+ const nowMs = args.nowMs ?? Date.now();
1392
+ const limit = args.limit ?? 100;
1393
+ const rows = await ctx.db
1394
+ .query("dataSnapshots")
1395
+ .withIndex("by_status_and_expiresAt", (q) =>
1396
+ q.eq("status", "ready").lte("expiresAt", nowMs),
1397
+ )
1398
+ .take(limit);
1399
+ for (const row of rows) {
1400
+ await ctx.db.patch(row._id, { status: "expired" });
1401
+ }
1402
+ return rows.length;
1403
+ },
1404
+ });
1405
+
1406
+ export const getWorkerStats = query({
1407
+ args: {},
1408
+ returns: v.object({
1409
+ activeCount: v.number(),
1410
+ idleCount: v.number(),
1411
+ workers: v.array(
1412
+ v.object({
1413
+ workerId: v.string(),
1414
+ status: v.union(
1415
+ v.literal("active"),
1416
+ v.literal("stopped"),
1417
+ ),
1418
+ load: v.number(),
1419
+ heartbeatAt: v.number(),
1420
+ machineId: v.union(v.null(), v.string()),
1421
+ appName: v.union(v.null(), v.string()),
1422
+ }),
1423
+ ),
1424
+ }),
1425
+ handler: async (ctx) => {
1426
+ const activeWorkers = await ctx.db
1427
+ .query("workers")
1428
+ .withIndex("by_status", (q) => q.eq("status", "active"))
1429
+ .collect();
1430
+
1431
+ const withLoad = activeWorkers.filter((w) => w.load > 0);
1432
+ const idle = activeWorkers.filter((w) => w.load === 0);
1433
+
1434
+ return {
1435
+ activeCount: withLoad.length,
1436
+ idleCount: idle.length,
1437
+ workers: activeWorkers.map((worker) => ({
1438
+ workerId: worker.workerId,
1439
+ status: worker.status,
1440
+ load: worker.load,
1441
+ heartbeatAt: worker.heartbeatAt,
1442
+ machineId: worker.machineRef?.machineId ?? null,
1443
+ appName: worker.machineRef?.appName ?? null,
1444
+ })),
1445
+ };
1446
+ },
1447
+ });
1448
+
1449
+ export const listHydrationRebuildTargets = internalQuery({
1450
+ args: {
1451
+ nowMs: v.optional(v.number()),
1452
+ limit: v.optional(v.number()),
1453
+ },
1454
+ returns: v.array(
1455
+ v.object({
1456
+ workspaceId: v.string(),
1457
+ agentKey: v.string(),
1458
+ }),
1459
+ ),
1460
+ handler: async (ctx, args) => {
1461
+ const nowMs = args.nowMs ?? Date.now();
1462
+ const limit = args.limit ?? 100;
1463
+ const expiredReady = await ctx.db
1464
+ .query("hydrationSnapshots")
1465
+ .withIndex("by_status_and_expiresAt", (q) =>
1466
+ q.eq("status", "ready").lte("expiresAt", nowMs),
1467
+ )
1468
+ .take(limit);
1469
+
1470
+ const stale = await ctx.db
1471
+ .query("hydrationSnapshots")
1472
+ .withIndex("by_status_and_expiresAt", (q) => q.eq("status", "stale"))
1473
+ .take(limit);
1474
+
1475
+ const unique = new Map<string, { workspaceId: string; agentKey: string }>();
1476
+ for (const row of [...expiredReady, ...stale]) {
1477
+ const key = `${row.workspaceId}:${row.agentKey}`;
1478
+ if (!unique.has(key)) {
1479
+ unique.set(key, { workspaceId: row.workspaceId, agentKey: row.agentKey });
1480
+ }
1481
+ if (unique.size >= limit) break;
1482
+ }
1483
+ return [...unique.values()];
1484
+ },
1485
+ });
1486
+
1487
+ function estimateTokens(
1488
+ promptSections: Array<{ section: string; content: string }>,
1489
+ memoryWindow: Array<{ path: string; excerpt: string }>,
1490
+ ): number {
1491
+ const sectionChars = promptSections.reduce(
1492
+ (sum, section) => sum + section.section.length + section.content.length,
1493
+ 0,
1494
+ );
1495
+ const memoryChars = memoryWindow.reduce(
1496
+ (sum, memory) => sum + memory.path.length + memory.excerpt.length,
1497
+ 0,
1498
+ );
1499
+ return Math.ceil((sectionChars + memoryChars) / 4);
1500
+ }
1501
+
1502
+ function fingerprintConversationDelta(
1503
+ deltaContext: Array<{ role: "system" | "user" | "assistant" | "tool"; content: string; at: number }>,
1504
+ ): string {
1505
+ const payload = deltaContext.map((entry) => `${entry.role}:${entry.at}:${entry.content}`).join("|");
1506
+ let hash = 2166136261;
1507
+ for (let index = 0; index < payload.length; index += 1) {
1508
+ hash ^= payload.charCodeAt(index);
1509
+ hash = Math.imul(hash, 16777619);
1510
+ }
1511
+ return `f${(hash >>> 0).toString(16)}`;
1512
+ }
1513
+
1514
+ function encryptSecretValue(plaintext: string): string {
1515
+ const units = Array.from(plaintext);
1516
+ return units
1517
+ .map((char, index) => {
1518
+ const code = char.charCodeAt(0);
1519
+ const mask = 11 + (index % 7);
1520
+ return (code ^ mask).toString(16).padStart(4, "0");
1521
+ })
1522
+ .join("");
1523
+ }
1524
+
1525
+ function decryptSecretValue(encryptedValue: string, algorithm: string): string {
1526
+ if (algorithm !== "xor-hex-v1") {
1527
+ throw new Error(`Unsupported secret algorithm '${algorithm}'`);
1528
+ }
1529
+ if (encryptedValue.length % 4 !== 0) {
1530
+ throw new Error("Invalid secret payload");
1531
+ }
1532
+
1533
+ let out = "";
1534
+ for (let i = 0; i < encryptedValue.length; i += 4) {
1535
+ const chunk = encryptedValue.slice(i, i + 4);
1536
+ const value = Number.parseInt(chunk, 16);
1537
+ if (Number.isNaN(value)) {
1538
+ throw new Error("Invalid secret payload");
1539
+ }
1540
+ const mask = 11 + ((i / 4) % 7);
1541
+ out += String.fromCharCode(value ^ mask);
1542
+ }
1543
+ return out;
1544
+ }