agent-relay-server 0.77.0 → 0.78.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/docs/openapi.json +79 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-Ct4RjObq.js → MessageBubble-Bkzw574C.js} +2 -2
- package/public/assets/{MessageBubble-Ct4RjObq.js.map → MessageBubble-Bkzw574C.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BQcLye6d.js → PlanGraphPanel-DaLJ5fKn.js} +2 -2
- package/public/assets/{PlanGraphPanel-BQcLye6d.js.map → PlanGraphPanel-DaLJ5fKn.js.map} +1 -1
- package/public/assets/{activity-DeQhQ17b.js → activity-BLFdfuxj.js} +2 -2
- package/public/assets/{activity-DeQhQ17b.js.map → activity-BLFdfuxj.js.map} +1 -1
- package/public/assets/{agents-coulRN38.js → agents-CEnKjxMD.js} +2 -2
- package/public/assets/{agents-coulRN38.js.map → agents-CEnKjxMD.js.map} +1 -1
- package/public/assets/{automation-V97mnoZp.js → automation-Crxi1JGO.js} +2 -2
- package/public/assets/{automation-V97mnoZp.js.map → automation-Crxi1JGO.js.map} +1 -1
- package/public/assets/{chat-B8fs8r1x.js → chat-CWmxVsVA.js} +2 -2
- package/public/assets/{chat-B8fs8r1x.js.map → chat-CWmxVsVA.js.map} +1 -1
- package/public/assets/{formatted-body-impl-CQ-8HQ7u.js → formatted-body-impl-CCA5xER4.js} +2 -2
- package/public/assets/{formatted-body-impl-CQ-8HQ7u.js.map → formatted-body-impl-CCA5xER4.js.map} +1 -1
- package/public/assets/{index-DJtda8l2.js → index-Du_Njun0.js} +5 -5
- package/public/assets/index-Du_Njun0.js.map +1 -0
- package/public/assets/{insights-CtT5cEif.js → insights-Dy4k6fpi.js} +2 -2
- package/public/assets/{insights-CtT5cEif.js.map → insights-Dy4k6fpi.js.map} +1 -1
- package/public/assets/{maintenance-BWLMjX8h.js → maintenance-Bgaxr-3J.js} +2 -2
- package/public/assets/{maintenance-BWLMjX8h.js.map → maintenance-Bgaxr-3J.js.map} +1 -1
- package/public/assets/{managed-agents-KxHV0VcQ.js → managed-agents-DKD0Ovct.js} +2 -2
- package/public/assets/{managed-agents-KxHV0VcQ.js.map → managed-agents-DKD0Ovct.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-C5b5cBM0.js → markdown-preview-impl-OM7F_OtZ.js} +2 -2
- package/public/assets/{markdown-preview-impl-C5b5cBM0.js.map → markdown-preview-impl-OM7F_OtZ.js.map} +1 -1
- package/public/assets/{memory-Bh3UgQCk.js → memory-BSvQoXx6.js} +2 -2
- package/public/assets/{memory-Bh3UgQCk.js.map → memory-BSvQoXx6.js.map} +1 -1
- package/public/assets/{messages-Dg7TSxpZ.js → messages-D-WqWrGU.js} +2 -2
- package/public/assets/{messages-Dg7TSxpZ.js.map → messages-D-WqWrGU.js.map} +1 -1
- package/public/assets/{orchestrators-BBKW7bO0.js → orchestrators-DZrmTmOK.js} +2 -2
- package/public/assets/{orchestrators-BBKW7bO0.js.map → orchestrators-DZrmTmOK.js.map} +1 -1
- package/public/assets/{overview-a8lG62AZ.js → overview-C8Q92ay4.js} +2 -2
- package/public/assets/{overview-a8lG62AZ.js.map → overview-C8Q92ay4.js.map} +1 -1
- package/public/assets/{pairs-ChSYXFwu.js → pairs-B_3Q2kaN.js} +2 -2
- package/public/assets/{pairs-ChSYXFwu.js.map → pairs-B_3Q2kaN.js.map} +1 -1
- package/public/assets/{security-D0obEjDT.js → security-CUiGx0a5.js} +2 -2
- package/public/assets/{security-D0obEjDT.js.map → security-CUiGx0a5.js.map} +1 -1
- package/public/assets/{select-DwCP2Jhh.js → select-Bh9XLp7J.js} +2 -2
- package/public/assets/{select-DwCP2Jhh.js.map → select-Bh9XLp7J.js.map} +1 -1
- package/public/assets/{settings-f1l2R0e8.js → settings-Dox7P4g0.js} +2 -2
- package/public/assets/{settings-f1l2R0e8.js.map → settings-Dox7P4g0.js.map} +1 -1
- package/public/assets/{tasks-DDVfmUst.js → tasks-BIP2A8PU.js} +2 -2
- package/public/assets/{tasks-DDVfmUst.js.map → tasks-BIP2A8PU.js.map} +1 -1
- package/public/assets/{teams-COZKriLe.js → teams-pbVU7Y_N.js} +2 -2
- package/public/assets/{teams-COZKriLe.js.map → teams-pbVU7Y_N.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CjOZ__aw.js → terminal-viewer-impl-M_3TITNG.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CjOZ__aw.js.map → terminal-viewer-impl-M_3TITNG.js.map} +1 -1
- package/public/assets/{work-queue-BC7heUfD.js → work-queue-D12yWpsi.js} +2 -2
- package/public/assets/{work-queue-BC7heUfD.js.map → work-queue-D12yWpsi.js.map} +1 -1
- package/public/index.html +1 -1
- package/src/config-store.ts +3 -1
- package/src/db/index.ts +1 -0
- package/src/db/migrations.ts +21 -0
- package/src/db/provider-quota-leases.ts +134 -0
- package/src/db/provider-quotas.ts +179 -1
- package/src/db/schema.ts +13 -0
- package/src/index.ts +2 -0
- package/src/notification-types.ts +2 -0
- package/src/quota-advisory.ts +300 -0
- package/src/quota-threshold-config.ts +46 -0
- package/src/routes/index.ts +3 -1
- package/src/routes/provider-quotas.ts +56 -2
- package/src/token-db.ts +1 -1
- package/public/assets/index-DJtda8l2.js.map +0 -1
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { getDb } from "./db/connection.ts";
|
|
3
|
+
import { emitRelayEvent } from "./events";
|
|
4
|
+
import { routeNotification } from "./notification-router";
|
|
5
|
+
import { DEFAULT_QUOTA_THRESHOLD_CONFIG, validateQuotaThresholdConfig } from "./quota-threshold-config";
|
|
6
|
+
import { parseJson } from "./utils";
|
|
7
|
+
import { setProviderQuotaAdvisoryHandler, type ProviderQuotaAdvisoryInput } from "./db/provider-quotas.ts";
|
|
8
|
+
import type { QuotaState } from "./types";
|
|
9
|
+
|
|
10
|
+
interface QuotaAdvisoryWindowState {
|
|
11
|
+
lastQuotaAdvisoryBand?: number;
|
|
12
|
+
lastQuotaAdvisoryAt?: number;
|
|
13
|
+
lastRemainingPercent?: number;
|
|
14
|
+
lastResetsAt?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type QuotaAdvisoryState = Record<string, QuotaAdvisoryWindowState>;
|
|
18
|
+
type QuotaAdvisoryStage = "warn" | "critical" | "reset";
|
|
19
|
+
|
|
20
|
+
type AgentRecipientRow = {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
kind?: string | null;
|
|
24
|
+
machine?: string | null;
|
|
25
|
+
rig?: string | null;
|
|
26
|
+
ready?: number | null;
|
|
27
|
+
status: string;
|
|
28
|
+
meta?: string | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const RESET_ROUNDING_PERCENT = 99;
|
|
32
|
+
|
|
33
|
+
export function installQuotaAdvisoryHandler(): void {
|
|
34
|
+
setProviderQuotaAdvisoryHandler(handleProviderQuotaAdvisory);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string {
|
|
38
|
+
const config = effectiveQuotaNotificationConfig().quotaThreshold;
|
|
39
|
+
const state = parseJson<QuotaAdvisoryState>(input.previousAdvisoryState ?? "{}", {});
|
|
40
|
+
const nextState: QuotaAdvisoryState = { ...state };
|
|
41
|
+
|
|
42
|
+
for (const window of input.quota.windows) {
|
|
43
|
+
const previousWindow = input.previousQuota?.windows.find((candidate) => candidate.name === window.name);
|
|
44
|
+
const remaining = quotaRemaining(window.utilization);
|
|
45
|
+
const remainingPercent = percent(remaining);
|
|
46
|
+
const previous = nextState[window.name];
|
|
47
|
+
const reset = previous?.lastQuotaAdvisoryBand !== undefined && isQuotaReset(remaining, previousWindow, window, config.resetRemaining);
|
|
48
|
+
if (reset) {
|
|
49
|
+
notifyQuotaAdvisory({
|
|
50
|
+
provider: input.provider,
|
|
51
|
+
accountKey: input.accountKey,
|
|
52
|
+
windowName: window.name,
|
|
53
|
+
stage: "reset",
|
|
54
|
+
band: 100,
|
|
55
|
+
remainingPercent,
|
|
56
|
+
resetsAt: window.resetsAt,
|
|
57
|
+
recipients: quotaAdvisoryRecipients(input.provider, input.accountKey, input.sourceAgentIds, input.now),
|
|
58
|
+
headroomHint: crossProviderHeadroomHint(input.provider),
|
|
59
|
+
now: input.now,
|
|
60
|
+
});
|
|
61
|
+
delete nextState[window.name];
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (remaining > config.warnRemaining) {
|
|
66
|
+
if (previous) delete nextState[window.name];
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const band = quotaRemainingBand(remaining, config.bandSize);
|
|
71
|
+
if (previous?.lastQuotaAdvisoryBand === band) continue;
|
|
72
|
+
const stage: QuotaAdvisoryStage = remaining <= config.criticalRemaining ? "critical" : "warn";
|
|
73
|
+
notifyQuotaAdvisory({
|
|
74
|
+
provider: input.provider,
|
|
75
|
+
accountKey: input.accountKey,
|
|
76
|
+
windowName: window.name,
|
|
77
|
+
stage,
|
|
78
|
+
band,
|
|
79
|
+
remainingPercent,
|
|
80
|
+
resetsAt: window.resetsAt,
|
|
81
|
+
recipients: quotaAdvisoryRecipients(input.provider, input.accountKey, input.sourceAgentIds, input.now),
|
|
82
|
+
headroomHint: crossProviderHeadroomHint(input.provider),
|
|
83
|
+
now: input.now,
|
|
84
|
+
});
|
|
85
|
+
nextState[window.name] = {
|
|
86
|
+
lastQuotaAdvisoryBand: band,
|
|
87
|
+
lastQuotaAdvisoryAt: input.now,
|
|
88
|
+
lastRemainingPercent: remainingPercent,
|
|
89
|
+
...(window.resetsAt !== undefined ? { lastResetsAt: window.resetsAt } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return JSON.stringify(nextState);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function notifyQuotaAdvisory(input: {
|
|
97
|
+
provider: string;
|
|
98
|
+
accountKey: string;
|
|
99
|
+
windowName: string;
|
|
100
|
+
stage: QuotaAdvisoryStage;
|
|
101
|
+
band: number;
|
|
102
|
+
remainingPercent: number;
|
|
103
|
+
resetsAt?: number;
|
|
104
|
+
recipients: string[];
|
|
105
|
+
headroomHint: string;
|
|
106
|
+
now: number;
|
|
107
|
+
}): void {
|
|
108
|
+
const payload = {
|
|
109
|
+
kind: "agent.quota_threshold",
|
|
110
|
+
provider: input.provider,
|
|
111
|
+
accountKey: input.accountKey,
|
|
112
|
+
window: input.windowName,
|
|
113
|
+
stage: input.stage,
|
|
114
|
+
band: input.band,
|
|
115
|
+
remainingPercent: input.remainingPercent,
|
|
116
|
+
...(input.resetsAt !== undefined ? { resetsAt: input.resetsAt } : {}),
|
|
117
|
+
recipients: input.recipients,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
emitRelayEvent({
|
|
121
|
+
type: "agent.quota_threshold",
|
|
122
|
+
source: "server",
|
|
123
|
+
subject: `${input.provider}:${input.accountKey}:${input.windowName}`,
|
|
124
|
+
data: payload,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const notifications = effectiveQuotaNotificationConfig();
|
|
128
|
+
if (!notifications.enabled || !notifications.quotaThreshold.enabled) return;
|
|
129
|
+
|
|
130
|
+
routeNotification({
|
|
131
|
+
type: "agent.quota_threshold",
|
|
132
|
+
scope: {},
|
|
133
|
+
recipients: input.recipients,
|
|
134
|
+
message: {
|
|
135
|
+
subject: input.stage === "reset"
|
|
136
|
+
? `${windowLabel(input.windowName)} ${providerLabel(input.provider)} quota reset`
|
|
137
|
+
: `${windowLabel(input.windowName)} ${providerLabel(input.provider)} quota ${input.stage === "critical" ? "critical" : "warning"}`,
|
|
138
|
+
body: quotaAdvisoryBody(input),
|
|
139
|
+
payload,
|
|
140
|
+
replyExpected: false,
|
|
141
|
+
},
|
|
142
|
+
emittedAt: input.now,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function effectiveQuotaNotificationConfig(): { enabled: boolean; quotaThreshold: ReturnType<typeof validateQuotaThresholdConfig> } {
|
|
147
|
+
const row = getDb().query("SELECT value FROM config WHERE namespace = 'notifications' AND key = 'default'").get() as { value?: string | null } | undefined;
|
|
148
|
+
const value = parseJson<Record<string, unknown>>(row?.value ?? "{}", {});
|
|
149
|
+
return {
|
|
150
|
+
enabled: value.enabled === undefined ? true : value.enabled === true,
|
|
151
|
+
quotaThreshold: validateQuotaThresholdConfig({ ...DEFAULT_QUOTA_THRESHOLD_CONFIG, ...(isRecord(value.quotaThreshold) ? value.quotaThreshold : {}) }),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function quotaAdvisoryBody(input: {
|
|
156
|
+
provider: string;
|
|
157
|
+
windowName: string;
|
|
158
|
+
stage: QuotaAdvisoryStage;
|
|
159
|
+
band: number;
|
|
160
|
+
remainingPercent: number;
|
|
161
|
+
resetsAt?: number;
|
|
162
|
+
headroomHint: string;
|
|
163
|
+
now: number;
|
|
164
|
+
}): string {
|
|
165
|
+
const provider = providerLabel(input.provider);
|
|
166
|
+
const window = windowLabel(input.windowName);
|
|
167
|
+
if (input.stage === "reset" || input.remainingPercent >= RESET_ROUNDING_PERCENT) {
|
|
168
|
+
return `${window} ${provider} quota reset to 100%. ${input.headroomHint}`;
|
|
169
|
+
}
|
|
170
|
+
const reset = input.resetsAt === undefined ? "reset time unknown" : `resets in ${formatDuration(input.resetsAt - input.now)}`;
|
|
171
|
+
return `${window} ${provider} quota below ${input.band}% remaining - ${input.remainingPercent}% remaining, ${reset}. ${input.headroomHint}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function quotaAdvisoryRecipients(provider: string, accountKey: string, sourceAgentIds: string[], now: number): string[] {
|
|
175
|
+
const sourceHosts = agentHosts(sourceAgentIds);
|
|
176
|
+
const accountHost = hostFromAccountKey(accountKey);
|
|
177
|
+
const candidates = getDb().query(`
|
|
178
|
+
SELECT id, name, kind, machine, rig, ready, status, meta
|
|
179
|
+
FROM agents
|
|
180
|
+
WHERE kind = 'provider'
|
|
181
|
+
AND ready = 1
|
|
182
|
+
AND status NOT IN ('offline', 'stale')
|
|
183
|
+
`).all() as AgentRecipientRow[];
|
|
184
|
+
return candidates
|
|
185
|
+
.filter((agent) => isSpawnCapableAgent(agent, now))
|
|
186
|
+
.filter((agent) => isBoundToQuotaAccount(agent, provider, accountKey, accountHost, sourceHosts, sourceAgentIds))
|
|
187
|
+
.map((agent) => agent.id);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isSpawnCapableAgent(agent: AgentRecipientRow, now: number): boolean {
|
|
191
|
+
const meta = parseJson<Record<string, unknown>>(agent.meta ?? "{}", {});
|
|
192
|
+
const auth = isRecord(meta.auth) ? meta.auth : undefined;
|
|
193
|
+
const jti = typeof auth?.jti === "string" ? auth.jti : undefined;
|
|
194
|
+
if (!jti) return false;
|
|
195
|
+
const row = getDb().query("SELECT scope, revoked_at, expires_at FROM tokens WHERE jti = ?").get(jti) as { scope: string; revoked_at?: number | null; expires_at?: number | null } | undefined;
|
|
196
|
+
if (!row || row.revoked_at) return false;
|
|
197
|
+
const nowSeconds = Math.floor(now / 1000);
|
|
198
|
+
if (row.expires_at !== null && row.expires_at !== undefined && row.expires_at <= nowSeconds) return false;
|
|
199
|
+
const scopes = parseJson<string[]>(row.scope, []);
|
|
200
|
+
return scopes.includes("command:spawn") || scopes.includes("command:*") || scopes.includes("admin:*") || scopes.includes("*");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isBoundToQuotaAccount(agent: AgentRecipientRow, provider: string, accountKey: string, accountHost: string | undefined, sourceHosts: Set<string>, sourceAgentIds: string[]): boolean {
|
|
204
|
+
if (sourceAgentIds.includes(agent.id)) return true;
|
|
205
|
+
const meta = parseJson<Record<string, unknown>>(agent.meta ?? "{}", {});
|
|
206
|
+
if (stringMeta(meta.provider)?.toLowerCase() === provider && accountKeyMatches(meta, accountKey)) return true;
|
|
207
|
+
if (accountKeyMatches(meta, accountKey)) return true;
|
|
208
|
+
const host = stringMeta(agent.machine) ?? stringMeta(agent.rig);
|
|
209
|
+
if (!host) return false;
|
|
210
|
+
return host === accountHost || sourceHosts.has(host);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function accountKeyMatches(meta: Record<string, unknown>, accountKey: string): boolean {
|
|
214
|
+
return [stringMeta(meta.accountKey), stringMeta(meta.accountId), stringMeta(meta.orgId)].includes(accountKey);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function agentHosts(agentIds: string[]): Set<string> {
|
|
218
|
+
const hosts = new Set<string>();
|
|
219
|
+
if (agentIds.length === 0) return hosts;
|
|
220
|
+
const rows = getDb().query(`
|
|
221
|
+
SELECT machine, rig FROM agents
|
|
222
|
+
WHERE id IN (${agentIds.map(() => "?").join(",")})
|
|
223
|
+
`).all(...agentIds) as Array<{ machine?: string | null; rig?: string | null }>;
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
const host = stringMeta(row.machine) ?? stringMeta(row.rig);
|
|
226
|
+
if (host) hosts.add(host);
|
|
227
|
+
}
|
|
228
|
+
return hosts;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function crossProviderHeadroomHint(provider: string): string {
|
|
232
|
+
const rows = getDb().query(`
|
|
233
|
+
SELECT provider, quota_state
|
|
234
|
+
FROM provider_quotas
|
|
235
|
+
WHERE provider <> ? AND quota_state IS NOT NULL
|
|
236
|
+
`).all(provider) as Array<{ provider: string; quota_state: string }>;
|
|
237
|
+
const options = rows.flatMap((row) => {
|
|
238
|
+
const quota = parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: 0 });
|
|
239
|
+
return quota.windows.map((window) => ({
|
|
240
|
+
provider: row.provider,
|
|
241
|
+
window: window.name,
|
|
242
|
+
remainingPercent: percent(quotaRemaining(window.utilization)),
|
|
243
|
+
}));
|
|
244
|
+
}).sort((a, b) => b.remainingPercent - a.remainingPercent);
|
|
245
|
+
const best = options[0];
|
|
246
|
+
if (!best) return "No separate provider headroom is currently recorded.";
|
|
247
|
+
return `${providerLabel(best.provider)} pool separate, ${windowLabel(best.window)} at ${best.remainingPercent}% remaining - route new work there.`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isQuotaReset(
|
|
251
|
+
remaining: number,
|
|
252
|
+
previousWindow: QuotaState["windows"][number] | undefined,
|
|
253
|
+
window: QuotaState["windows"][number],
|
|
254
|
+
resetRemaining: number,
|
|
255
|
+
): boolean {
|
|
256
|
+
if (remaining >= resetRemaining) return true;
|
|
257
|
+
if (!previousWindow?.resetsAt || !window.resetsAt || window.resetsAt <= previousWindow.resetsAt) return false;
|
|
258
|
+
return remaining - quotaRemaining(previousWindow.utilization) >= 0.5 && remaining >= 0.8;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function quotaRemainingBand(remaining: number, bandSize: number): number {
|
|
262
|
+
return Math.min(100, Math.max(0, percent(Math.ceil(remaining / bandSize) * bandSize)));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function quotaRemaining(utilization: number): number {
|
|
266
|
+
return Math.max(0, Math.min(1, 1 - utilization));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function percent(value: number): number {
|
|
270
|
+
return Math.round(value * 100);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function hostFromAccountKey(accountKey: string): string | undefined {
|
|
274
|
+
if (!accountKey.startsWith("host:")) return undefined;
|
|
275
|
+
const host = accountKey.slice("host:".length);
|
|
276
|
+
return host && !host.includes(":") ? host : undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function stringMeta(value: unknown): string | undefined {
|
|
280
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function providerLabel(provider: string): string {
|
|
284
|
+
return provider.slice(0, 1).toUpperCase() + provider.slice(1);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function windowLabel(value: string): string {
|
|
288
|
+
if (value === "five_hour") return "5h";
|
|
289
|
+
if (value === "seven_day") return "7d";
|
|
290
|
+
return value.replace(/_/g, " ");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function formatDuration(ms: number): string {
|
|
294
|
+
if (!Number.isFinite(ms) || ms <= 0) return "now";
|
|
295
|
+
const minutes = Math.max(1, Math.round(ms / 60_000));
|
|
296
|
+
if (minutes < 60) return `${minutes}m`;
|
|
297
|
+
const hours = Math.round(minutes / 60);
|
|
298
|
+
if (hours < 48) return `${hours}h`;
|
|
299
|
+
return `${Math.round(hours / 24)}d`;
|
|
300
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { ValidationError } from "./db/connection.ts";
|
|
3
|
+
import type { NotificationsConfig } from "./types";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_QUOTA_THRESHOLD_CONFIG: NotificationsConfig["quotaThreshold"] = {
|
|
6
|
+
enabled: true,
|
|
7
|
+
warnRemaining: 0.25,
|
|
8
|
+
criticalRemaining: 0.1,
|
|
9
|
+
resetRemaining: 0.99,
|
|
10
|
+
bandSize: 0.05,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function validateQuotaThresholdConfig(value: unknown): NotificationsConfig["quotaThreshold"] {
|
|
14
|
+
const input = isRecord(value) ? value : {};
|
|
15
|
+
const warnRemaining = cleanFraction(input.warnRemaining, "quotaThreshold.warnRemaining", DEFAULT_QUOTA_THRESHOLD_CONFIG.warnRemaining);
|
|
16
|
+
const criticalRemaining = cleanFraction(input.criticalRemaining, "quotaThreshold.criticalRemaining", DEFAULT_QUOTA_THRESHOLD_CONFIG.criticalRemaining);
|
|
17
|
+
const resetRemaining = cleanFraction(input.resetRemaining, "quotaThreshold.resetRemaining", DEFAULT_QUOTA_THRESHOLD_CONFIG.resetRemaining);
|
|
18
|
+
const bandSize = cleanFraction(input.bandSize, "quotaThreshold.bandSize", DEFAULT_QUOTA_THRESHOLD_CONFIG.bandSize);
|
|
19
|
+
if (bandSize <= 0) throw new ValidationError("quotaThreshold.bandSize must be greater than 0");
|
|
20
|
+
if (criticalRemaining >= warnRemaining) {
|
|
21
|
+
throw new ValidationError("quotaThreshold.criticalRemaining must be less than quotaThreshold.warnRemaining");
|
|
22
|
+
}
|
|
23
|
+
if (resetRemaining <= warnRemaining) {
|
|
24
|
+
throw new ValidationError("quotaThreshold.resetRemaining must be greater than quotaThreshold.warnRemaining");
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
enabled: input.enabled === undefined ? DEFAULT_QUOTA_THRESHOLD_CONFIG.enabled : cleanBoolean(input.enabled, "quotaThreshold.enabled"),
|
|
28
|
+
warnRemaining,
|
|
29
|
+
criticalRemaining,
|
|
30
|
+
resetRemaining,
|
|
31
|
+
bandSize,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cleanBoolean(value: unknown, field: string): boolean {
|
|
36
|
+
if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cleanFraction(value: unknown, field: string, fallback: number): number {
|
|
41
|
+
if (value === undefined || value === null) return fallback;
|
|
42
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
43
|
+
throw new ValidationError(`${field} must be a number between 0 and 1`);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
}
|
package/src/routes/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { deleteAgentTerminalSession, postAgentAction, postAgentPermissionDecisio
|
|
|
5
5
|
import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
|
|
6
6
|
import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomations, patchAutomation, postAutomation, postAutomationRun } from "./automations";
|
|
7
7
|
import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
|
|
8
|
-
import { getProviderQuotasRoute, postProviderQuotaRoute } from "./provider-quotas";
|
|
8
|
+
import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
|
|
9
9
|
import { deleteConfigKey, getConfigKey, getConfigKeyHistory, getConfigNamespace, putConfigKey } from "./config";
|
|
10
10
|
import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute, patchInboxThreadState, postChatHistoryImportRoute, putInboxDraft } from "./inbox";
|
|
11
11
|
import { deleteMemoryRoute, getMemories, getMemoryBrokerInfo, getMemoryById, getMemoryStats, patchMemory, postMemory, postMemoryInject, postMemorySearch } from "./memory";
|
|
@@ -90,6 +90,8 @@ const routes: Route[] = [
|
|
|
90
90
|
route("POST", "/api/route", postRouteAdvice),
|
|
91
91
|
route("GET", "/api/provider-quotas", getProviderQuotasRoute),
|
|
92
92
|
route("POST", "/api/provider-quotas", postProviderQuotaRoute),
|
|
93
|
+
route("POST", "/api/orchestrators/:id/provider-quota-leases/acquire", postProviderQuotaLeaseAcquireRoute),
|
|
94
|
+
route("POST", "/api/orchestrators/:id/provider-quota-leases/release", postProviderQuotaLeaseReleaseRoute),
|
|
93
95
|
route("GET", "/api/agents/spawn/directories", getHostDirectories),
|
|
94
96
|
route("GET", "/api/agents/:id/chat", getAgentChatHistory),
|
|
95
97
|
route("GET", "/api/agents/:id/context-snapshots", getAgentContextSnapshots),
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
|
-
import { listProviderQuotas, upsertProviderQuota } from "../db";
|
|
2
|
+
import { acquireProviderQuotaLease, getOrchestrator, listProviderQuotas, releaseProviderQuotaLease, upsertProviderQuota } from "../db";
|
|
3
3
|
import { quotaStateFromUnknown, providerQuotaErrorFromUnknown } from "../quota";
|
|
4
4
|
import { cleanString } from "../validation";
|
|
5
|
-
import { error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
|
|
5
|
+
import { authorizeRoute, error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
|
|
6
6
|
import type { ProviderQuotaUpdateInput } from "../types";
|
|
7
7
|
|
|
8
8
|
function cleanProviderQuotaUpdate(body: unknown): ProviderQuotaUpdateInput | string {
|
|
@@ -46,3 +46,57 @@ export const postProviderQuotaRoute: Handler = async (req) => {
|
|
|
46
46
|
if (typeof input === "string") return error(input);
|
|
47
47
|
return json(upsertProviderQuota(input), 201);
|
|
48
48
|
};
|
|
49
|
+
|
|
50
|
+
function cleanLeaseBody(body: unknown): {
|
|
51
|
+
provider: string;
|
|
52
|
+
accountKey: string;
|
|
53
|
+
leaseToken?: string;
|
|
54
|
+
ttlMs: number;
|
|
55
|
+
} | string {
|
|
56
|
+
if (!isRecord(body)) return "JSON object body required";
|
|
57
|
+
const provider = cleanString(body.provider, "provider", { required: true, max: 80 })!;
|
|
58
|
+
const accountKey = cleanString(body.accountKey, "accountKey", { required: true, max: 240 })!;
|
|
59
|
+
const leaseToken = cleanString(body.leaseToken, "leaseToken", { max: 120 });
|
|
60
|
+
const ttlMs = typeof body.ttlMs === "number" && Number.isFinite(body.ttlMs)
|
|
61
|
+
? Math.max(5_000, Math.min(10 * 60_000, Math.round(body.ttlMs)))
|
|
62
|
+
: 90_000;
|
|
63
|
+
return { provider, accountKey, ...(leaseToken ? { leaseToken } : {}), ttlMs };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const postProviderQuotaLeaseAcquireRoute: Handler = async (req, params) => {
|
|
67
|
+
const orch = getOrchestrator(params.id!);
|
|
68
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
69
|
+
const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
|
|
70
|
+
if (denied) return denied;
|
|
71
|
+
const parsed = await parseBody<unknown>(req);
|
|
72
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
73
|
+
const input = cleanLeaseBody(parsed.body);
|
|
74
|
+
if (typeof input === "string") return error(input);
|
|
75
|
+
return json(acquireProviderQuotaLease({
|
|
76
|
+
provider: input.provider,
|
|
77
|
+
accountKey: input.accountKey,
|
|
78
|
+
orchestratorId: orch.id,
|
|
79
|
+
leaseToken: input.leaseToken,
|
|
80
|
+
ttlMs: input.ttlMs,
|
|
81
|
+
}));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const postProviderQuotaLeaseReleaseRoute: Handler = async (req, params) => {
|
|
85
|
+
const orch = getOrchestrator(params.id!);
|
|
86
|
+
if (!orch) return error("orchestrator not found", 404);
|
|
87
|
+
const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
|
|
88
|
+
if (denied) return denied;
|
|
89
|
+
const parsed = await parseBody<unknown>(req);
|
|
90
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
91
|
+
const input = cleanLeaseBody(parsed.body);
|
|
92
|
+
if (typeof input === "string") return error(input);
|
|
93
|
+
if (!input.leaseToken) return error("leaseToken required");
|
|
94
|
+
return json({
|
|
95
|
+
released: releaseProviderQuotaLease({
|
|
96
|
+
provider: input.provider,
|
|
97
|
+
accountKey: input.accountKey,
|
|
98
|
+
orchestratorId: orch.id,
|
|
99
|
+
leaseToken: input.leaseToken,
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
};
|
package/src/token-db.ts
CHANGED
|
@@ -67,7 +67,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
|
|
|
67
67
|
name: "Orchestrator Host",
|
|
68
68
|
description: "Host supervisor access for spawn, command, log, and terminal operations.",
|
|
69
69
|
role: "orchestrator",
|
|
70
|
-
scope: ["agent:read", "agent:write", "command:read", "command:write", "task:read", "task:write", "logs:read", "terminal:attach"],
|
|
70
|
+
scope: ["agent:read", "agent:write", "command:read", "command:write", "task:read", "task:write", "logs:read", "terminal:attach", "quota:write"],
|
|
71
71
|
constraints: { logsRead: true, terminalAttach: true },
|
|
72
72
|
ttlSeconds: 30 * 24 * 60 * 60,
|
|
73
73
|
builtIn: true,
|