agent-relay-server 0.88.0 → 0.88.2

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.2",
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.2",
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": {
@@ -45,6 +45,10 @@ type ProviderQuotaSnapshotRow = {
45
45
  last_error?: string | null;
46
46
  };
47
47
 
48
+ type StoredQuotaState = QuotaState & {
49
+ _windowUpdatedAt?: Record<string, number>;
50
+ };
51
+
48
52
  function cleanKey(value: string): string {
49
53
  return value.trim();
50
54
  }
@@ -185,6 +189,118 @@ function quotaFingerprint(quota: QuotaState): string {
185
189
  })).sort((a, b) => a.name.localeCompare(b.name) || a.unit.localeCompare(b.unit)));
186
190
  }
187
191
 
192
+ function quotaWindowKey(window: QuotaState["windows"][number]): string {
193
+ return `${window.name}:${window.unit}`;
194
+ }
195
+
196
+ function publicQuotaState(quota: StoredQuotaState): QuotaState {
197
+ return {
198
+ windows: quota.windows.map((window) => ({ ...window })),
199
+ source: quota.source,
200
+ updatedAt: quota.updatedAt,
201
+ };
202
+ }
203
+
204
+ function parseStoredQuotaState(value: string, fallback: QuotaState): StoredQuotaState {
205
+ const quota = parseJson<StoredQuotaState>(value, fallback);
206
+ return {
207
+ ...publicQuotaState(quota),
208
+ ...(quota._windowUpdatedAt && typeof quota._windowUpdatedAt === "object" ? { _windowUpdatedAt: quota._windowUpdatedAt } : {}),
209
+ };
210
+ }
211
+
212
+ function storedQuotaWindowSampleTimes(quota: StoredQuotaState): Map<string, number> {
213
+ const times = new Map<string, number>();
214
+ const rawTimes = quota._windowUpdatedAt && typeof quota._windowUpdatedAt === "object" ? quota._windowUpdatedAt : {};
215
+ for (const window of quota.windows) {
216
+ const key = quotaWindowKey(window);
217
+ const value = rawTimes[key];
218
+ if (typeof value === "number" && Number.isFinite(value)) times.set(key, value);
219
+ }
220
+ return times;
221
+ }
222
+
223
+ function quotaWithWindowSampleTimes(quota: QuotaState, times: Map<string, number>): StoredQuotaState {
224
+ const windowUpdatedAt: Record<string, number> = {};
225
+ for (const window of quota.windows) {
226
+ const timestamp = times.get(quotaWindowKey(window));
227
+ if (timestamp !== undefined) windowUpdatedAt[quotaWindowKey(window)] = timestamp;
228
+ }
229
+ return Object.keys(windowUpdatedAt).length > 0
230
+ ? { ...quota, _windowUpdatedAt: windowUpdatedAt }
231
+ : quota;
232
+ }
233
+
234
+ function quotaWindowSampleTimes(provider: string, accountKey: string, fallback?: StoredQuotaState): Map<string, number> {
235
+ const times = new Map<string, number>();
236
+ const storedTimes = fallback ? storedQuotaWindowSampleTimes(fallback) : new Map<string, number>();
237
+ for (const [key, timestamp] of storedTimes) times.set(key, timestamp);
238
+ const rows = getDb().query(`
239
+ SELECT quota_state FROM quota_snapshots
240
+ WHERE provider = ? AND account_key = ? AND unavailable = 0
241
+ ORDER BY captured_at DESC, id DESC
242
+ `).all(provider, accountKey) as Array<{ quota_state: string }>;
243
+ for (const row of rows) {
244
+ const quota = parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: 0 });
245
+ for (const window of quota.windows) {
246
+ const key = quotaWindowKey(window);
247
+ if (storedTimes.has(key)) continue;
248
+ times.set(key, Math.max(times.get(key) ?? 0, quota.updatedAt));
249
+ }
250
+ }
251
+ if (fallback) {
252
+ for (const window of fallback.windows) {
253
+ const key = quotaWindowKey(window);
254
+ if (!times.has(key)) times.set(key, fallback.updatedAt);
255
+ }
256
+ }
257
+ return times;
258
+ }
259
+
260
+ function mergeQuotaWindows(existing: StoredQuotaState | undefined, incoming: QuotaState, provider: string, accountKey: string): {
261
+ quota: StoredQuotaState;
262
+ acceptedIncomingWindow: boolean;
263
+ } {
264
+ if (!existing) {
265
+ const times = new Map(incoming.windows.map((window) => [quotaWindowKey(window), incoming.updatedAt]));
266
+ return { quota: quotaWithWindowSampleTimes(incoming, times), acceptedIncomingWindow: true };
267
+ }
268
+ const existingTimes = quotaWindowSampleTimes(provider, accountKey, existing);
269
+ const incomingByKey = new Map(incoming.windows.map((window) => [quotaWindowKey(window), window]));
270
+ const mergedTimes = new Map<string, number>();
271
+ let acceptedIncomingWindow = false;
272
+ let updatedAt = existing.updatedAt;
273
+ let source = existing.source;
274
+ const windows = existing.windows.map((window) => {
275
+ const key = quotaWindowKey(window);
276
+ const incomingWindow = incomingByKey.get(key);
277
+ const existingUpdatedAt = existingTimes.get(key) ?? existing.updatedAt;
278
+ if (!incomingWindow) {
279
+ mergedTimes.set(key, existingUpdatedAt);
280
+ return window;
281
+ }
282
+ if (incoming.updatedAt < existingUpdatedAt) {
283
+ mergedTimes.set(key, existingUpdatedAt);
284
+ return window;
285
+ }
286
+ acceptedIncomingWindow = true;
287
+ updatedAt = Math.max(updatedAt, incoming.updatedAt);
288
+ source = incoming.source;
289
+ mergedTimes.set(key, incoming.updatedAt);
290
+ return incomingWindow;
291
+ });
292
+ const existingKeys = new Set(existing.windows.map(quotaWindowKey));
293
+ for (const window of incoming.windows) {
294
+ if (existingKeys.has(quotaWindowKey(window))) continue;
295
+ acceptedIncomingWindow = true;
296
+ windows.push(window);
297
+ updatedAt = Math.max(updatedAt, incoming.updatedAt);
298
+ source = incoming.source;
299
+ mergedTimes.set(quotaWindowKey(window), incoming.updatedAt);
300
+ }
301
+ return { quota: quotaWithWindowSampleTimes({ windows, source, updatedAt }, mergedTimes), acceptedIncomingWindow };
302
+ }
303
+
188
304
  function rowToSnapshot(row: ProviderQuotaSnapshotRow): ProviderQuotaSnapshot {
189
305
  const base = {
190
306
  provider: row.provider,
@@ -199,14 +315,14 @@ function rowToSnapshot(row: ProviderQuotaSnapshotRow): ProviderQuotaSnapshot {
199
315
  }
200
316
  return {
201
317
  ...base,
202
- quota: parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: row.captured_at }),
318
+ quota: publicQuotaState(parseStoredQuotaState(row.quota_state, { windows: [], source: "probe", updatedAt: row.captured_at })),
203
319
  };
204
320
  }
205
321
 
206
322
  function rowToRecord(row: ProviderQuotaRow, history: ProviderQuotaSnapshot[], now: number): ProviderQuotaRecord {
207
323
  const lastError = parseJson<ProviderQuotaError | undefined>(row.last_error, undefined);
208
324
  const lastUpdatedAt = row.last_updated_at ?? undefined;
209
- const quota = row.quota_state ? parseJson<QuotaState>(row.quota_state, { windows: [], source: "probe", updatedAt: lastUpdatedAt ?? row.last_attempt_at }) : undefined;
325
+ const quota = row.quota_state ? publicQuotaState(parseStoredQuotaState(row.quota_state, { windows: [], source: "probe", updatedAt: lastUpdatedAt ?? row.last_attempt_at })) : undefined;
210
326
  return {
211
327
  provider: row.provider,
212
328
  accountKey: row.account_key,
@@ -263,12 +379,14 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
263
379
  const existing = getDb().query("SELECT * FROM provider_quotas WHERE provider = ? AND account_key = ?").get(provider, accountKey) as ProviderQuotaRow | undefined;
264
380
  const sourceAgentIds = normalizeSourceAgentIds(existing?.source_agent_ids);
265
381
  if (input.sourceAgentId && !sourceAgentIds.includes(input.sourceAgentId)) sourceAgentIds.push(input.sourceAgentId);
266
- const lastUpdatedAt = input.quota ? input.quota.updatedAt : existing?.last_updated_at ?? undefined;
382
+ const existingQuota = existing?.quota_state ? parseStoredQuotaState(existing.quota_state, { windows: [], source: "probe", updatedAt: existing.last_updated_at ?? 0 }) : undefined;
383
+ const mergedQuota = input.quota ? mergeQuotaWindows(existingQuota, input.quota, provider, accountKey) : undefined;
384
+ const lastUpdatedAt = mergedQuota ? mergedQuota.quota.updatedAt : existing?.last_updated_at ?? undefined;
267
385
  const lastAttemptAt = input.lastAttemptAt ?? input.quota?.updatedAt ?? now;
268
386
  const lastError = input.lastError === undefined
269
387
  ? input.quota ? null : existing?.last_error ?? null
270
388
  : input.lastError;
271
- const quotaState = input.quota ? JSON.stringify(input.quota) : existing?.quota_state ?? null;
389
+ const quotaState = mergedQuota ? JSON.stringify(mergedQuota.quota) : existing?.quota_state ?? null;
272
390
 
273
391
  getDb().query(`
274
392
  INSERT INTO provider_quotas (
@@ -295,19 +413,20 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
295
413
  now,
296
414
  );
297
415
 
298
- if (input.quota && latestSnapshotFingerprint(provider, accountKey) !== quotaFingerprint(input.quota)) {
299
- recordProviderQuotaSnapshot({ provider, accountKey, quota: input.quota, sourceAgentId: input.sourceAgentId }, now);
416
+ const snapshotQuota = mergedQuota?.acceptedIncomingWindow ? publicQuotaState(mergedQuota.quota) : undefined;
417
+ if (snapshotQuota && latestSnapshotFingerprint(provider, accountKey) !== quotaFingerprint(snapshotQuota)) {
418
+ recordProviderQuotaSnapshot({ provider, accountKey, quota: snapshotQuota, sourceAgentId: input.sourceAgentId }, now);
300
419
  } else if (!input.quota && input.lastError?.type === "unavailable" && !latestSnapshotIsUnavailable(provider, accountKey)) {
301
420
  // #609 — the provider couldn't be collected (orchestrator skip, #601). Record one explicit gap
302
421
  // marker on the transition into unavailable (deduped while it stays unavailable) so history shows
303
422
  // an honest gap rather than a silent absence; the next successful poll closes the gap.
304
423
  recordProviderQuotaUnavailable({ provider, accountKey, error: input.lastError, sourceAgentId: input.sourceAgentId }, now);
305
424
  }
306
- if (hasFreshSuccessfulProviderQuota(input, now)) {
425
+ if (hasFreshSuccessfulProviderQuota(input, now) && mergedQuota?.acceptedIncomingWindow) {
307
426
  const quotaAdvisoryState = providerQuotaAdvisoryHandler?.({
308
427
  provider,
309
428
  accountKey,
310
- previousQuota: existing?.quota_state ? parseJson<QuotaState>(existing.quota_state, { windows: [], source: "probe", updatedAt: 0 }) : undefined,
429
+ previousQuota: existingQuota ? publicQuotaState(existingQuota) : undefined,
311
430
  quota: input.quota,
312
431
  sourceAgentIds,
313
432
  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.