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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.88.0",
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.0",
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.66",
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 lastUpdatedAt = input.quota ? input.quota.updatedAt : existing?.last_updated_at ?? undefined;
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 = input.quota ? JSON.stringify(input.quota) : existing?.quota_state ?? null;
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: existing?.quota_state ? parseJson<QuotaState>(existing.quota_state, { windows: [], source: "probe", updatedAt: 0 }) : undefined,
371
+ previousQuota: existingQuota,
311
372
  quota: input.quota,
312
373
  sourceAgentIds,
313
374
  previousAdvisoryState: existing?.quota_advisory_state,
@@ -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.