agent-relay-server 0.88.0 → 0.88.1
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 +59 -1
- package/package.json +2 -2
- package/src/db/provider-quotas.ts +65 -4
- package/src/routes/index.ts +3 -1
- package/src/routes/provider-quotas.ts +50 -0
- package/src/security.ts +3 -0
package/docs/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Relay API",
|
|
5
|
-
"version": "0.88.
|
|
5
|
+
"version": "0.88.1",
|
|
6
6
|
"description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
|
|
7
7
|
"license": {
|
|
8
8
|
"name": "MIT",
|
|
@@ -1161,6 +1161,64 @@
|
|
|
1161
1161
|
]
|
|
1162
1162
|
}
|
|
1163
1163
|
},
|
|
1164
|
+
"/api/provider-quota-leases/acquire": {
|
|
1165
|
+
"post": {
|
|
1166
|
+
"operationId": "postProviderQuotaPublisherLeaseAcquireRoute",
|
|
1167
|
+
"summary": "Acquire provider quota publisher lease",
|
|
1168
|
+
"tags": [
|
|
1169
|
+
"Agents"
|
|
1170
|
+
],
|
|
1171
|
+
"description": "Runner-scoped election endpoint. Grants or renews the single publisher lease for one `(provider, accountKey)` when no non-expired holder exists, or returns the current holder and retry delay. The provider token must be allowed to act as the supplied `sourceAgentId`.",
|
|
1172
|
+
"responses": {
|
|
1173
|
+
"200": {
|
|
1174
|
+
"description": "Success",
|
|
1175
|
+
"content": {
|
|
1176
|
+
"application/json": {}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
},
|
|
1180
|
+
"security": [
|
|
1181
|
+
{
|
|
1182
|
+
"bearerAuth": []
|
|
1183
|
+
},
|
|
1184
|
+
{
|
|
1185
|
+
"tokenHeader": []
|
|
1186
|
+
},
|
|
1187
|
+
{
|
|
1188
|
+
"tokenQuery": []
|
|
1189
|
+
}
|
|
1190
|
+
]
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
"/api/provider-quota-leases/release": {
|
|
1194
|
+
"post": {
|
|
1195
|
+
"operationId": "postProviderQuotaPublisherLeaseReleaseRoute",
|
|
1196
|
+
"summary": "Release provider quota publisher lease",
|
|
1197
|
+
"tags": [
|
|
1198
|
+
"Agents"
|
|
1199
|
+
],
|
|
1200
|
+
"description": "Releases a provider quota publisher lease only when `provider`, `accountKey`, `sourceAgentId`, and `leaseToken` all match the current runner holder.",
|
|
1201
|
+
"responses": {
|
|
1202
|
+
"200": {
|
|
1203
|
+
"description": "Success",
|
|
1204
|
+
"content": {
|
|
1205
|
+
"application/json": {}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
},
|
|
1209
|
+
"security": [
|
|
1210
|
+
{
|
|
1211
|
+
"bearerAuth": []
|
|
1212
|
+
},
|
|
1213
|
+
{
|
|
1214
|
+
"tokenHeader": []
|
|
1215
|
+
},
|
|
1216
|
+
{
|
|
1217
|
+
"tokenQuery": []
|
|
1218
|
+
}
|
|
1219
|
+
]
|
|
1220
|
+
}
|
|
1221
|
+
},
|
|
1164
1222
|
"/api/orchestrators/{id}/provider-quota-leases/acquire": {
|
|
1165
1223
|
"post": {
|
|
1166
1224
|
"operationId": "postProviderQuotaLeaseAcquireRoute",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.88.
|
|
3
|
+
"version": "0.88.1",
|
|
4
4
|
"description": "Lightweight HTTP message relay for inter-agent communication across machines",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"CONTRIBUTING.md"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"agent-relay-sdk": "0.2.
|
|
36
|
+
"agent-relay-sdk": "0.2.67",
|
|
37
37
|
"ajv": "^8.20.0"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
@@ -185,6 +185,65 @@ function quotaFingerprint(quota: QuotaState): string {
|
|
|
185
185
|
})).sort((a, b) => a.name.localeCompare(b.name) || a.unit.localeCompare(b.unit)));
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
function quotaWindowKey(window: QuotaState["windows"][number]): string {
|
|
189
|
+
return `${window.name}:${window.unit}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function quotaWindowSampleTimes(provider: string, accountKey: string, fallback?: QuotaState): Map<string, number> {
|
|
193
|
+
const times = new Map<string, number>();
|
|
194
|
+
const rows = getDb().query(`
|
|
195
|
+
SELECT quota_state FROM quota_snapshots
|
|
196
|
+
WHERE provider = ? AND account_key = ? AND unavailable = 0
|
|
197
|
+
ORDER BY captured_at DESC, id DESC
|
|
198
|
+
`).all(provider, accountKey) as Array<{ quota_state: string }>;
|
|
199
|
+
for (const row of rows) {
|
|
200
|
+
const quota = parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: 0 });
|
|
201
|
+
for (const window of quota.windows) {
|
|
202
|
+
const key = quotaWindowKey(window);
|
|
203
|
+
times.set(key, Math.max(times.get(key) ?? 0, quota.updatedAt));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (fallback) {
|
|
207
|
+
for (const window of fallback.windows) {
|
|
208
|
+
const key = quotaWindowKey(window);
|
|
209
|
+
if (!times.has(key)) times.set(key, fallback.updatedAt);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return times;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function mergeQuotaWindows(existing: QuotaState | undefined, incoming: QuotaState, provider: string, accountKey: string): {
|
|
216
|
+
quota: QuotaState;
|
|
217
|
+
acceptedIncomingWindow: boolean;
|
|
218
|
+
} {
|
|
219
|
+
if (!existing) return { quota: incoming, acceptedIncomingWindow: true };
|
|
220
|
+
const existingTimes = quotaWindowSampleTimes(provider, accountKey, existing);
|
|
221
|
+
const incomingByKey = new Map(incoming.windows.map((window) => [quotaWindowKey(window), window]));
|
|
222
|
+
let acceptedIncomingWindow = false;
|
|
223
|
+
let updatedAt = existing.updatedAt;
|
|
224
|
+
let source = existing.source;
|
|
225
|
+
const windows = existing.windows.map((window) => {
|
|
226
|
+
const key = quotaWindowKey(window);
|
|
227
|
+
const incomingWindow = incomingByKey.get(key);
|
|
228
|
+
if (!incomingWindow) return window;
|
|
229
|
+
const existingUpdatedAt = existingTimes.get(key) ?? existing.updatedAt;
|
|
230
|
+
if (incoming.updatedAt < existingUpdatedAt) return window;
|
|
231
|
+
acceptedIncomingWindow = true;
|
|
232
|
+
updatedAt = Math.max(updatedAt, incoming.updatedAt);
|
|
233
|
+
source = incoming.source;
|
|
234
|
+
return incomingWindow;
|
|
235
|
+
});
|
|
236
|
+
const existingKeys = new Set(existing.windows.map(quotaWindowKey));
|
|
237
|
+
for (const window of incoming.windows) {
|
|
238
|
+
if (existingKeys.has(quotaWindowKey(window))) continue;
|
|
239
|
+
acceptedIncomingWindow = true;
|
|
240
|
+
windows.push(window);
|
|
241
|
+
updatedAt = Math.max(updatedAt, incoming.updatedAt);
|
|
242
|
+
source = incoming.source;
|
|
243
|
+
}
|
|
244
|
+
return { quota: { windows, source, updatedAt }, acceptedIncomingWindow };
|
|
245
|
+
}
|
|
246
|
+
|
|
188
247
|
function rowToSnapshot(row: ProviderQuotaSnapshotRow): ProviderQuotaSnapshot {
|
|
189
248
|
const base = {
|
|
190
249
|
provider: row.provider,
|
|
@@ -263,12 +322,14 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
|
|
|
263
322
|
const existing = getDb().query("SELECT * FROM provider_quotas WHERE provider = ? AND account_key = ?").get(provider, accountKey) as ProviderQuotaRow | undefined;
|
|
264
323
|
const sourceAgentIds = normalizeSourceAgentIds(existing?.source_agent_ids);
|
|
265
324
|
if (input.sourceAgentId && !sourceAgentIds.includes(input.sourceAgentId)) sourceAgentIds.push(input.sourceAgentId);
|
|
266
|
-
const
|
|
325
|
+
const existingQuota = existing?.quota_state ? parseJson<QuotaState>(existing.quota_state, { windows: [], source: "probe", updatedAt: existing.last_updated_at ?? 0 }) : undefined;
|
|
326
|
+
const mergedQuota = input.quota ? mergeQuotaWindows(existingQuota, input.quota, provider, accountKey) : undefined;
|
|
327
|
+
const lastUpdatedAt = mergedQuota ? mergedQuota.quota.updatedAt : existing?.last_updated_at ?? undefined;
|
|
267
328
|
const lastAttemptAt = input.lastAttemptAt ?? input.quota?.updatedAt ?? now;
|
|
268
329
|
const lastError = input.lastError === undefined
|
|
269
330
|
? input.quota ? null : existing?.last_error ?? null
|
|
270
331
|
: input.lastError;
|
|
271
|
-
const quotaState =
|
|
332
|
+
const quotaState = mergedQuota ? JSON.stringify(mergedQuota.quota) : existing?.quota_state ?? null;
|
|
272
333
|
|
|
273
334
|
getDb().query(`
|
|
274
335
|
INSERT INTO provider_quotas (
|
|
@@ -303,11 +364,11 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
|
|
|
303
364
|
// an honest gap rather than a silent absence; the next successful poll closes the gap.
|
|
304
365
|
recordProviderQuotaUnavailable({ provider, accountKey, error: input.lastError, sourceAgentId: input.sourceAgentId }, now);
|
|
305
366
|
}
|
|
306
|
-
if (hasFreshSuccessfulProviderQuota(input, now)) {
|
|
367
|
+
if (hasFreshSuccessfulProviderQuota(input, now) && mergedQuota?.acceptedIncomingWindow) {
|
|
307
368
|
const quotaAdvisoryState = providerQuotaAdvisoryHandler?.({
|
|
308
369
|
provider,
|
|
309
370
|
accountKey,
|
|
310
|
-
previousQuota:
|
|
371
|
+
previousQuota: existingQuota,
|
|
311
372
|
quota: input.quota,
|
|
312
373
|
sourceAgentIds,
|
|
313
374
|
previousAdvisoryState: existing?.quota_advisory_state,
|
package/src/routes/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { deleteMyAvatar, deleteUserAvatarById, getMe, getUserAvatar, getUsers, h
|
|
|
6
6
|
import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
|
|
7
7
|
import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomations, patchAutomation, postAutomation, postAutomationRun } from "./automations";
|
|
8
8
|
import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
|
|
9
|
-
import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
|
|
9
|
+
import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaPublisherLeaseAcquireRoute, postProviderQuotaPublisherLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
|
|
10
10
|
import { getProviderQuotaConfigRoute, getProviderQuotaConfigsRoute, putProviderQuotaConfigRoute } from "./provider-quota-config";
|
|
11
11
|
import { getTeamsConfigRoute, putTeamsConfigRoute } from "./teams-config";
|
|
12
12
|
import { deleteConfigKey, getConfigKey, getConfigKeyHistory, getConfigNamespace, putConfigKey } from "./config";
|
|
@@ -112,6 +112,8 @@ const routes: Route[] = [
|
|
|
112
112
|
route("POST", "/api/route", postRouteAdvice),
|
|
113
113
|
route("GET", "/api/provider-quotas", getProviderQuotasRoute),
|
|
114
114
|
route("POST", "/api/provider-quotas", postProviderQuotaRoute),
|
|
115
|
+
route("POST", "/api/provider-quota-leases/acquire", postProviderQuotaPublisherLeaseAcquireRoute),
|
|
116
|
+
route("POST", "/api/provider-quota-leases/release", postProviderQuotaPublisherLeaseReleaseRoute),
|
|
115
117
|
route("POST", "/api/orchestrators/:id/provider-quota-leases/acquire", postProviderQuotaLeaseAcquireRoute),
|
|
116
118
|
route("POST", "/api/orchestrators/:id/provider-quota-leases/release", postProviderQuotaLeaseReleaseRoute),
|
|
117
119
|
route("GET", "/api/agents/spawn/directories", getHostDirectories),
|
|
@@ -66,6 +66,56 @@ function cleanLeaseBody(body: unknown): {
|
|
|
66
66
|
return { provider, accountKey, ...(leaseToken ? { leaseToken } : {}), ttlMs };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
function cleanPublisherLeaseBody(body: unknown): {
|
|
70
|
+
provider: string;
|
|
71
|
+
accountKey: string;
|
|
72
|
+
sourceAgentId: string;
|
|
73
|
+
leaseToken?: string;
|
|
74
|
+
ttlMs: number;
|
|
75
|
+
} | string {
|
|
76
|
+
const input = cleanLeaseBody(body);
|
|
77
|
+
if (typeof input === "string") return input;
|
|
78
|
+
if (!isRecord(body)) return "JSON object body required";
|
|
79
|
+
const sourceAgentId = cleanString(body.sourceAgentId, "sourceAgentId", { required: true, max: 200 })!;
|
|
80
|
+
return { ...input, sourceAgentId };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const runnerLeaseHolderId = (sourceAgentId: string) => `runner:${sourceAgentId}`;
|
|
84
|
+
|
|
85
|
+
export const postProviderQuotaPublisherLeaseAcquireRoute: Handler = async (req) => {
|
|
86
|
+
const parsed = await parseBody<unknown>(req);
|
|
87
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
88
|
+
const input = cleanPublisherLeaseBody(parsed.body);
|
|
89
|
+
if (typeof input === "string") return error(input);
|
|
90
|
+
const denied = authorizeRoute(req, { scope: "quota:write", resource: { agentId: input.sourceAgentId } });
|
|
91
|
+
if (denied) return denied;
|
|
92
|
+
return json(acquireProviderQuotaLease({
|
|
93
|
+
provider: input.provider,
|
|
94
|
+
accountKey: input.accountKey,
|
|
95
|
+
orchestratorId: runnerLeaseHolderId(input.sourceAgentId),
|
|
96
|
+
leaseToken: input.leaseToken,
|
|
97
|
+
ttlMs: input.ttlMs,
|
|
98
|
+
}));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const postProviderQuotaPublisherLeaseReleaseRoute: Handler = async (req) => {
|
|
102
|
+
const parsed = await parseBody<unknown>(req);
|
|
103
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
104
|
+
const input = cleanPublisherLeaseBody(parsed.body);
|
|
105
|
+
if (typeof input === "string") return error(input);
|
|
106
|
+
if (!input.leaseToken) return error("leaseToken required");
|
|
107
|
+
const denied = authorizeRoute(req, { scope: "quota:write", resource: { agentId: input.sourceAgentId } });
|
|
108
|
+
if (denied) return denied;
|
|
109
|
+
return json({
|
|
110
|
+
released: releaseProviderQuotaLease({
|
|
111
|
+
provider: input.provider,
|
|
112
|
+
accountKey: input.accountKey,
|
|
113
|
+
orchestratorId: runnerLeaseHolderId(input.sourceAgentId),
|
|
114
|
+
leaseToken: input.leaseToken,
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
69
119
|
export const postProviderQuotaLeaseAcquireRoute: Handler = async (req, params) => {
|
|
70
120
|
const orch = getOrchestrator(params.id!);
|
|
71
121
|
if (!orch) return error("orchestrator not found", 404);
|
package/src/security.ts
CHANGED
|
@@ -195,6 +195,8 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
195
195
|
if (pathname.startsWith("/api/maintenance")) return "system:admin";
|
|
196
196
|
if (pathname.startsWith("/api/tasks")) return method === "GET" ? "task:read" : "task:write";
|
|
197
197
|
if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
|
|
198
|
+
if (pathname === "/api/provider-quota-leases/acquire" || pathname === "/api/provider-quota-leases/release") return "quota:write";
|
|
199
|
+
if (pathname === "/api/provider-quotas") return method === "GET" ? "agent:read" : "quota:write";
|
|
198
200
|
if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
|
|
199
201
|
if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
|
|
200
202
|
if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
|
|
@@ -277,6 +279,7 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
|
|
|
277
279
|
// Spawn requires the spawn capability (see requiredScopeFor above) — matches the in-service gate.
|
|
278
280
|
if (pathname === "/api/agents/spawn") return "command:spawn";
|
|
279
281
|
if (pathname.startsWith("/api/agents")) return method === "GET" ? "agent:read" : "agent:write";
|
|
282
|
+
if (pathname === "/api/provider-quota-leases/acquire" || pathname === "/api/provider-quota-leases/release") return "quota:write";
|
|
280
283
|
if (pathname === "/api/provider-quotas") return method === "GET" ? "agent:read" : "quota:write";
|
|
281
284
|
// Per-provider quota config (#605): reads are agent:read (the orchestrator
|
|
282
285
|
// collector reads its config each tick); writes are settings:write:provider.
|