@okrlinkhub/agent-bridge 0.1.0 → 0.2.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/dist/client/index.d.ts +89 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +279 -4
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +4 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +103 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/channels.d.ts +83 -0
- package/dist/component/channels.d.ts.map +1 -0
- package/dist/component/channels.js +288 -0
- package/dist/component/channels.js.map +1 -0
- package/dist/component/circuitBreaker.d.ts +73 -0
- package/dist/component/circuitBreaker.d.ts.map +1 -0
- package/dist/component/circuitBreaker.js +216 -0
- package/dist/component/circuitBreaker.js.map +1 -0
- package/dist/component/gateway.d.ts +19 -3
- package/dist/component/gateway.d.ts.map +1 -1
- package/dist/component/gateway.js +82 -2
- package/dist/component/gateway.js.map +1 -1
- package/dist/component/permissions.d.ts +2 -2
- package/dist/component/registry.d.ts +3 -3
- package/dist/component/schema.d.ts +73 -3
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +46 -0
- package/dist/component/schema.js.map +1 -1
- package/package.json +5 -2
- package/src/client/index.ts +411 -4
- package/src/component/_generated/api.ts +4 -0
- package/src/component/_generated/component.ts +142 -1
- package/src/component/channels.ts +374 -0
- package/src/component/circuitBreaker.ts +250 -0
- package/src/component/gateway.ts +98 -2
- package/src/component/schema.ts +49 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation, query } from "./_generated/server.js";
|
|
3
|
+
import type { MutationCtx, QueryCtx } from "./_generated/server.js";
|
|
4
|
+
|
|
5
|
+
// --- Token hashing utility (shared with gateway.ts) ---
|
|
6
|
+
|
|
7
|
+
async function hashToken(token: string): Promise<string> {
|
|
8
|
+
const encoder = new TextEncoder();
|
|
9
|
+
const data = encoder.encode(token);
|
|
10
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
11
|
+
return Array.from(new Uint8Array(hash))
|
|
12
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
13
|
+
.join("");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// --- Instance verification helper ---
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Verify an instance token and return the agent info.
|
|
20
|
+
* Throws if the token is invalid, expired, or doesn't match the expected app.
|
|
21
|
+
*/
|
|
22
|
+
async function verifyInstanceToken(
|
|
23
|
+
ctx: QueryCtx | MutationCtx,
|
|
24
|
+
instanceToken: string,
|
|
25
|
+
appName: string,
|
|
26
|
+
): Promise<{ agentId: string; appName: string }> {
|
|
27
|
+
const tokenHash = await hashToken(instanceToken);
|
|
28
|
+
const instance = await ctx.db
|
|
29
|
+
.query("agentAppInstances")
|
|
30
|
+
.withIndex("by_instance_token_hash", (q) =>
|
|
31
|
+
q.eq("instanceTokenHash", tokenHash),
|
|
32
|
+
)
|
|
33
|
+
.unique();
|
|
34
|
+
|
|
35
|
+
if (!instance) {
|
|
36
|
+
throw new Error("Invalid instance token");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (instance.expiresAt < Date.now()) {
|
|
40
|
+
throw new Error("Instance token has expired");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (instance.appName !== appName) {
|
|
44
|
+
throw new Error("Token does not match this app");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Verify agent is active
|
|
48
|
+
const agent = await ctx.db
|
|
49
|
+
.query("registeredAgents")
|
|
50
|
+
.withIndex("by_agent_id", (q) => q.eq("agentId", instance.agentId))
|
|
51
|
+
.unique();
|
|
52
|
+
|
|
53
|
+
if (!agent || !agent.isActive) {
|
|
54
|
+
throw new Error("Agent has been revoked");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { agentId: instance.agentId, appName: instance.appName };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Channel Management ---
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create a channel for an app.
|
|
64
|
+
* Idempotent: if the channel already exists, returns the existing ID.
|
|
65
|
+
*/
|
|
66
|
+
export const createChannel = mutation({
|
|
67
|
+
args: {
|
|
68
|
+
appName: v.string(),
|
|
69
|
+
channelName: v.string(),
|
|
70
|
+
description: v.optional(v.string()),
|
|
71
|
+
},
|
|
72
|
+
returns: v.string(),
|
|
73
|
+
handler: async (ctx, args) => {
|
|
74
|
+
// Check if channel already exists
|
|
75
|
+
const existing = await ctx.db
|
|
76
|
+
.query("appChannels")
|
|
77
|
+
.withIndex("by_appName_and_channelName", (q) =>
|
|
78
|
+
q.eq("appName", args.appName).eq("channelName", args.channelName),
|
|
79
|
+
)
|
|
80
|
+
.unique();
|
|
81
|
+
|
|
82
|
+
if (existing) {
|
|
83
|
+
return existing._id;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const id = await ctx.db.insert("appChannels", {
|
|
87
|
+
appName: args.appName,
|
|
88
|
+
channelName: args.channelName,
|
|
89
|
+
description: args.description,
|
|
90
|
+
createdAt: Date.now(),
|
|
91
|
+
isActive: true,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return id;
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* List active channels for an app.
|
|
100
|
+
*/
|
|
101
|
+
export const listChannels = query({
|
|
102
|
+
args: {
|
|
103
|
+
appName: v.string(),
|
|
104
|
+
},
|
|
105
|
+
returns: v.array(
|
|
106
|
+
v.object({
|
|
107
|
+
channelName: v.string(),
|
|
108
|
+
description: v.optional(v.string()),
|
|
109
|
+
createdAt: v.number(),
|
|
110
|
+
isActive: v.boolean(),
|
|
111
|
+
}),
|
|
112
|
+
),
|
|
113
|
+
handler: async (ctx, args) => {
|
|
114
|
+
const channels = await ctx.db
|
|
115
|
+
.query("appChannels")
|
|
116
|
+
.withIndex("by_appName_and_channelName", (q) =>
|
|
117
|
+
q.eq("appName", args.appName),
|
|
118
|
+
)
|
|
119
|
+
.collect();
|
|
120
|
+
|
|
121
|
+
return channels
|
|
122
|
+
.filter((c) => c.isActive)
|
|
123
|
+
.map((c) => ({
|
|
124
|
+
channelName: c.channelName,
|
|
125
|
+
description: c.description,
|
|
126
|
+
createdAt: c.createdAt,
|
|
127
|
+
isActive: c.isActive,
|
|
128
|
+
}));
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Deactivate a channel (soft delete).
|
|
134
|
+
*/
|
|
135
|
+
export const deactivateChannel = mutation({
|
|
136
|
+
args: {
|
|
137
|
+
appName: v.string(),
|
|
138
|
+
channelName: v.string(),
|
|
139
|
+
},
|
|
140
|
+
returns: v.boolean(),
|
|
141
|
+
handler: async (ctx, args) => {
|
|
142
|
+
const channel = await ctx.db
|
|
143
|
+
.query("appChannels")
|
|
144
|
+
.withIndex("by_appName_and_channelName", (q) =>
|
|
145
|
+
q.eq("appName", args.appName).eq("channelName", args.channelName),
|
|
146
|
+
)
|
|
147
|
+
.unique();
|
|
148
|
+
|
|
149
|
+
if (!channel) {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await ctx.db.patch(channel._id, { isActive: false });
|
|
154
|
+
return true;
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// --- Message Operations ---
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Post a message to a channel.
|
|
162
|
+
* Requires a valid instanceToken for authentication.
|
|
163
|
+
*/
|
|
164
|
+
export const postMessage = mutation({
|
|
165
|
+
args: {
|
|
166
|
+
instanceToken: v.string(),
|
|
167
|
+
appName: v.string(),
|
|
168
|
+
channelName: v.string(),
|
|
169
|
+
payload: v.string(),
|
|
170
|
+
priority: v.optional(v.number()),
|
|
171
|
+
ttlMinutes: v.optional(v.number()),
|
|
172
|
+
},
|
|
173
|
+
returns: v.object({
|
|
174
|
+
success: v.boolean(),
|
|
175
|
+
messageId: v.string(),
|
|
176
|
+
}),
|
|
177
|
+
handler: async (ctx, args) => {
|
|
178
|
+
// Verify the agent's identity
|
|
179
|
+
const verified = await verifyInstanceToken(
|
|
180
|
+
ctx,
|
|
181
|
+
args.instanceToken,
|
|
182
|
+
args.appName,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// Verify channel exists and is active
|
|
186
|
+
const channel = await ctx.db
|
|
187
|
+
.query("appChannels")
|
|
188
|
+
.withIndex("by_appName_and_channelName", (q) =>
|
|
189
|
+
q.eq("appName", args.appName).eq("channelName", args.channelName),
|
|
190
|
+
)
|
|
191
|
+
.unique();
|
|
192
|
+
|
|
193
|
+
if (!channel || !channel.isActive) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Channel "${args.channelName}" not found or inactive in app "${args.appName}"`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const ttlMs = (args.ttlMinutes ?? 60) * 60 * 1000;
|
|
200
|
+
const messageId = crypto.randomUUID();
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
|
|
203
|
+
await ctx.db.insert("channelMessages", {
|
|
204
|
+
appName: args.appName,
|
|
205
|
+
channelName: args.channelName,
|
|
206
|
+
messageId,
|
|
207
|
+
fromAgentId: verified.agentId,
|
|
208
|
+
payload: args.payload,
|
|
209
|
+
metadata: {
|
|
210
|
+
priority: args.priority ?? 5,
|
|
211
|
+
ttl: ttlMs,
|
|
212
|
+
},
|
|
213
|
+
sentAt: now,
|
|
214
|
+
expiresAt: now + ttlMs,
|
|
215
|
+
readBy: [],
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return { success: true, messageId };
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Read messages from a channel with cursor-based pagination.
|
|
224
|
+
* Filters out expired messages. Returns messages in descending order (newest first).
|
|
225
|
+
*/
|
|
226
|
+
export const readMessages = query({
|
|
227
|
+
args: {
|
|
228
|
+
instanceToken: v.string(),
|
|
229
|
+
appName: v.string(),
|
|
230
|
+
channelName: v.string(),
|
|
231
|
+
limit: v.optional(v.number()),
|
|
232
|
+
after: v.optional(v.number()),
|
|
233
|
+
},
|
|
234
|
+
returns: v.array(
|
|
235
|
+
v.object({
|
|
236
|
+
messageId: v.string(),
|
|
237
|
+
fromAgentId: v.string(),
|
|
238
|
+
payload: v.string(),
|
|
239
|
+
metadata: v.object({
|
|
240
|
+
priority: v.number(),
|
|
241
|
+
ttl: v.number(),
|
|
242
|
+
}),
|
|
243
|
+
sentAt: v.number(),
|
|
244
|
+
expiresAt: v.number(),
|
|
245
|
+
readBy: v.array(v.string()),
|
|
246
|
+
}),
|
|
247
|
+
),
|
|
248
|
+
handler: async (ctx, args) => {
|
|
249
|
+
// Verify the agent's identity
|
|
250
|
+
await verifyInstanceToken(ctx, args.instanceToken, args.appName);
|
|
251
|
+
|
|
252
|
+
const now = Date.now();
|
|
253
|
+
const limit = args.limit ?? 20;
|
|
254
|
+
|
|
255
|
+
let messagesQuery;
|
|
256
|
+
if (args.after !== undefined) {
|
|
257
|
+
messagesQuery = ctx.db
|
|
258
|
+
.query("channelMessages")
|
|
259
|
+
.withIndex("by_appName_and_channelName_and_sentAt", (q) =>
|
|
260
|
+
q
|
|
261
|
+
.eq("appName", args.appName)
|
|
262
|
+
.eq("channelName", args.channelName)
|
|
263
|
+
.gt("sentAt", args.after!),
|
|
264
|
+
);
|
|
265
|
+
} else {
|
|
266
|
+
messagesQuery = ctx.db
|
|
267
|
+
.query("channelMessages")
|
|
268
|
+
.withIndex("by_appName_and_channelName_and_sentAt", (q) =>
|
|
269
|
+
q
|
|
270
|
+
.eq("appName", args.appName)
|
|
271
|
+
.eq("channelName", args.channelName),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const messages = await messagesQuery.order("desc").take(limit * 2);
|
|
276
|
+
|
|
277
|
+
// Filter out expired messages and take the requested limit
|
|
278
|
+
const validMessages = messages
|
|
279
|
+
.filter((m) => m.expiresAt > now)
|
|
280
|
+
.slice(0, limit);
|
|
281
|
+
|
|
282
|
+
return validMessages.map((m) => ({
|
|
283
|
+
messageId: m.messageId,
|
|
284
|
+
fromAgentId: m.fromAgentId,
|
|
285
|
+
payload: m.payload,
|
|
286
|
+
metadata: m.metadata,
|
|
287
|
+
sentAt: m.sentAt,
|
|
288
|
+
expiresAt: m.expiresAt,
|
|
289
|
+
readBy: m.readBy,
|
|
290
|
+
}));
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Mark a message as read by an agent.
|
|
296
|
+
* Adds the agentId to the readBy array if not already present.
|
|
297
|
+
*/
|
|
298
|
+
export const markAsRead = mutation({
|
|
299
|
+
args: {
|
|
300
|
+
instanceToken: v.string(),
|
|
301
|
+
appName: v.string(),
|
|
302
|
+
messageId: v.string(),
|
|
303
|
+
},
|
|
304
|
+
returns: v.boolean(),
|
|
305
|
+
handler: async (ctx, args) => {
|
|
306
|
+
const verified = await verifyInstanceToken(
|
|
307
|
+
ctx,
|
|
308
|
+
args.instanceToken,
|
|
309
|
+
args.appName,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// Find the message by messageId
|
|
313
|
+
const messages = await ctx.db
|
|
314
|
+
.query("channelMessages")
|
|
315
|
+
.withIndex("by_appName_and_channelName_and_sentAt", (q) =>
|
|
316
|
+
q.eq("appName", args.appName),
|
|
317
|
+
)
|
|
318
|
+
.collect();
|
|
319
|
+
|
|
320
|
+
const message = messages.find((m) => m.messageId === args.messageId);
|
|
321
|
+
|
|
322
|
+
if (!message) {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (message.readBy.includes(verified.agentId)) {
|
|
327
|
+
// Already marked as read
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
await ctx.db.patch(message._id, {
|
|
332
|
+
readBy: [...message.readBy, verified.agentId],
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
return true;
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Get count of unread messages for an agent on a channel.
|
|
341
|
+
* Only counts non-expired messages.
|
|
342
|
+
*/
|
|
343
|
+
export const getUnreadCount = query({
|
|
344
|
+
args: {
|
|
345
|
+
instanceToken: v.string(),
|
|
346
|
+
appName: v.string(),
|
|
347
|
+
channelName: v.string(),
|
|
348
|
+
},
|
|
349
|
+
returns: v.number(),
|
|
350
|
+
handler: async (ctx, args) => {
|
|
351
|
+
const verified = await verifyInstanceToken(
|
|
352
|
+
ctx,
|
|
353
|
+
args.instanceToken,
|
|
354
|
+
args.appName,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const now = Date.now();
|
|
358
|
+
|
|
359
|
+
const messages = await ctx.db
|
|
360
|
+
.query("channelMessages")
|
|
361
|
+
.withIndex("by_appName_and_channelName_and_sentAt", (q) =>
|
|
362
|
+
q
|
|
363
|
+
.eq("appName", args.appName)
|
|
364
|
+
.eq("channelName", args.channelName),
|
|
365
|
+
)
|
|
366
|
+
.collect();
|
|
367
|
+
|
|
368
|
+
const unread = messages.filter(
|
|
369
|
+
(m) => m.expiresAt > now && !m.readBy.includes(verified.agentId),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
return unread.length;
|
|
373
|
+
},
|
|
374
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { v } from "convex/values";
|
|
2
|
+
import { mutation, query } from "./_generated/server.js";
|
|
3
|
+
|
|
4
|
+
// --- Helpers ---
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get the current hour window string (e.g. "2026-02-08T14").
|
|
8
|
+
*/
|
|
9
|
+
function getCurrentWindowHour(): string {
|
|
10
|
+
return new Date().toISOString().slice(0, 13);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Calculate seconds remaining until the current hour window expires.
|
|
15
|
+
*/
|
|
16
|
+
export function secondsUntilWindowExpires(): number {
|
|
17
|
+
const now = new Date();
|
|
18
|
+
const nextHour = new Date(now);
|
|
19
|
+
nextHour.setMinutes(0, 0, 0);
|
|
20
|
+
nextHour.setHours(nextHour.getHours() + 1);
|
|
21
|
+
return Math.ceil((nextHour.getTime() - now.getTime()) / 1000);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// --- Validators ---
|
|
25
|
+
|
|
26
|
+
const checkResultValidator = v.object({
|
|
27
|
+
allowed: v.boolean(),
|
|
28
|
+
reason: v.optional(v.string()),
|
|
29
|
+
currentCount: v.number(),
|
|
30
|
+
currentTokens: v.number(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const statusValidator = v.object({
|
|
34
|
+
agentId: v.string(),
|
|
35
|
+
appName: v.string(),
|
|
36
|
+
windowHour: v.string(),
|
|
37
|
+
requestCount: v.number(),
|
|
38
|
+
tokenEstimate: v.number(),
|
|
39
|
+
isBlocked: v.boolean(),
|
|
40
|
+
blockedReason: v.optional(v.string()),
|
|
41
|
+
blockedAt: v.optional(v.number()),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// --- Public functions ---
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check the circuit breaker and increment counters if allowed.
|
|
48
|
+
* This is designed to be called from within the gateway's authorizeRequest
|
|
49
|
+
* mutation, but is also exposed for direct use.
|
|
50
|
+
*
|
|
51
|
+
* Looks up or creates the counter for the current hour window, checks
|
|
52
|
+
* if the agent is already blocked, then increments request count and
|
|
53
|
+
* token estimate. If limits are exceeded, blocks the agent for the
|
|
54
|
+
* remainder of the hour window.
|
|
55
|
+
*/
|
|
56
|
+
export const checkAndIncrement = mutation({
|
|
57
|
+
args: {
|
|
58
|
+
agentId: v.string(),
|
|
59
|
+
appName: v.string(),
|
|
60
|
+
estimatedCost: v.number(),
|
|
61
|
+
limits: v.object({
|
|
62
|
+
requestsPerHour: v.number(),
|
|
63
|
+
tokenBudget: v.number(),
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
returns: checkResultValidator,
|
|
67
|
+
handler: async (ctx, args) => {
|
|
68
|
+
const windowHour = getCurrentWindowHour();
|
|
69
|
+
|
|
70
|
+
// Find or create counter for this window
|
|
71
|
+
let counter = await ctx.db
|
|
72
|
+
.query("circuitCounters")
|
|
73
|
+
.withIndex("by_agentId_and_appName_and_windowHour", (q) =>
|
|
74
|
+
q
|
|
75
|
+
.eq("agentId", args.agentId)
|
|
76
|
+
.eq("appName", args.appName)
|
|
77
|
+
.eq("windowHour", windowHour),
|
|
78
|
+
)
|
|
79
|
+
.unique();
|
|
80
|
+
|
|
81
|
+
if (!counter) {
|
|
82
|
+
// Create new counter for this window
|
|
83
|
+
const id = await ctx.db.insert("circuitCounters", {
|
|
84
|
+
agentId: args.agentId,
|
|
85
|
+
appName: args.appName,
|
|
86
|
+
windowHour,
|
|
87
|
+
requestCount: 0,
|
|
88
|
+
tokenEstimate: 0,
|
|
89
|
+
isBlocked: false,
|
|
90
|
+
});
|
|
91
|
+
counter = (await ctx.db.get(id))!;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// If already blocked, deny immediately
|
|
95
|
+
if (counter.isBlocked) {
|
|
96
|
+
return {
|
|
97
|
+
allowed: false,
|
|
98
|
+
reason: counter.blockedReason ?? "Circuit breaker is open",
|
|
99
|
+
currentCount: counter.requestCount,
|
|
100
|
+
currentTokens: counter.tokenEstimate,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check if this request would exceed limits
|
|
105
|
+
const newRequestCount = counter.requestCount + 1;
|
|
106
|
+
const newTokenEstimate = counter.tokenEstimate + args.estimatedCost;
|
|
107
|
+
|
|
108
|
+
const requestsExceeded = newRequestCount > args.limits.requestsPerHour;
|
|
109
|
+
const tokensExceeded = newTokenEstimate > args.limits.tokenBudget;
|
|
110
|
+
|
|
111
|
+
if (requestsExceeded || tokensExceeded) {
|
|
112
|
+
// Block the circuit breaker
|
|
113
|
+
const reason = requestsExceeded
|
|
114
|
+
? `Requests per hour exceeded (${newRequestCount}/${args.limits.requestsPerHour})`
|
|
115
|
+
: `Token budget exceeded (${newTokenEstimate}/${args.limits.tokenBudget})`;
|
|
116
|
+
|
|
117
|
+
await ctx.db.patch(counter._id, {
|
|
118
|
+
requestCount: newRequestCount,
|
|
119
|
+
tokenEstimate: newTokenEstimate,
|
|
120
|
+
isBlocked: true,
|
|
121
|
+
blockedReason: reason,
|
|
122
|
+
blockedAt: Date.now(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
allowed: false,
|
|
127
|
+
reason,
|
|
128
|
+
currentCount: newRequestCount,
|
|
129
|
+
currentTokens: newTokenEstimate,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Increment counters
|
|
134
|
+
await ctx.db.patch(counter._id, {
|
|
135
|
+
requestCount: newRequestCount,
|
|
136
|
+
tokenEstimate: newTokenEstimate,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
allowed: true,
|
|
141
|
+
currentCount: newRequestCount,
|
|
142
|
+
currentTokens: newTokenEstimate,
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get the current circuit breaker status for an agent on an app.
|
|
149
|
+
* Returns the counter for the current hour window.
|
|
150
|
+
*/
|
|
151
|
+
export const getStatus = query({
|
|
152
|
+
args: {
|
|
153
|
+
agentId: v.string(),
|
|
154
|
+
appName: v.string(),
|
|
155
|
+
},
|
|
156
|
+
returns: v.union(statusValidator, v.null()),
|
|
157
|
+
handler: async (ctx, args) => {
|
|
158
|
+
const windowHour = getCurrentWindowHour();
|
|
159
|
+
|
|
160
|
+
const counter = await ctx.db
|
|
161
|
+
.query("circuitCounters")
|
|
162
|
+
.withIndex("by_agentId_and_appName_and_windowHour", (q) =>
|
|
163
|
+
q
|
|
164
|
+
.eq("agentId", args.agentId)
|
|
165
|
+
.eq("appName", args.appName)
|
|
166
|
+
.eq("windowHour", windowHour),
|
|
167
|
+
)
|
|
168
|
+
.unique();
|
|
169
|
+
|
|
170
|
+
if (!counter) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
agentId: counter.agentId,
|
|
176
|
+
appName: counter.appName,
|
|
177
|
+
windowHour: counter.windowHour,
|
|
178
|
+
requestCount: counter.requestCount,
|
|
179
|
+
tokenEstimate: counter.tokenEstimate,
|
|
180
|
+
isBlocked: counter.isBlocked,
|
|
181
|
+
blockedReason: counter.blockedReason,
|
|
182
|
+
blockedAt: counter.blockedAt,
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Admin function to manually reset a blocked circuit breaker.
|
|
189
|
+
* Creates a fresh counter for the current window with zeroed values.
|
|
190
|
+
*/
|
|
191
|
+
export const resetCounter = mutation({
|
|
192
|
+
args: {
|
|
193
|
+
agentId: v.string(),
|
|
194
|
+
appName: v.string(),
|
|
195
|
+
},
|
|
196
|
+
returns: v.boolean(),
|
|
197
|
+
handler: async (ctx, args) => {
|
|
198
|
+
const windowHour = getCurrentWindowHour();
|
|
199
|
+
|
|
200
|
+
const counter = await ctx.db
|
|
201
|
+
.query("circuitCounters")
|
|
202
|
+
.withIndex("by_agentId_and_appName_and_windowHour", (q) =>
|
|
203
|
+
q
|
|
204
|
+
.eq("agentId", args.agentId)
|
|
205
|
+
.eq("appName", args.appName)
|
|
206
|
+
.eq("windowHour", windowHour),
|
|
207
|
+
)
|
|
208
|
+
.unique();
|
|
209
|
+
|
|
210
|
+
if (!counter) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
await ctx.db.patch(counter._id, {
|
|
215
|
+
requestCount: 0,
|
|
216
|
+
tokenEstimate: 0,
|
|
217
|
+
isBlocked: false,
|
|
218
|
+
blockedReason: undefined,
|
|
219
|
+
blockedAt: undefined,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return true;
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* List all currently blocked circuit breakers.
|
|
228
|
+
* Useful for admin dashboards.
|
|
229
|
+
*/
|
|
230
|
+
export const listBlocked = query({
|
|
231
|
+
args: {},
|
|
232
|
+
returns: v.array(statusValidator),
|
|
233
|
+
handler: async (ctx) => {
|
|
234
|
+
const blocked = await ctx.db
|
|
235
|
+
.query("circuitCounters")
|
|
236
|
+
.withIndex("by_isBlocked", (q) => q.eq("isBlocked", true))
|
|
237
|
+
.collect();
|
|
238
|
+
|
|
239
|
+
return blocked.map((c) => ({
|
|
240
|
+
agentId: c.agentId,
|
|
241
|
+
appName: c.appName,
|
|
242
|
+
windowHour: c.windowHour,
|
|
243
|
+
requestCount: c.requestCount,
|
|
244
|
+
tokenEstimate: c.tokenEstimate,
|
|
245
|
+
isBlocked: c.isBlocked,
|
|
246
|
+
blockedReason: c.blockedReason,
|
|
247
|
+
blockedAt: c.blockedAt,
|
|
248
|
+
}));
|
|
249
|
+
},
|
|
250
|
+
});
|