agent-relay-server 0.97.0 → 0.98.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.
Files changed (80) hide show
  1. package/docs/openapi.json +55 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-CIDyX8f7.js → MessageBubble-C4AeEHdz.js} +2 -2
  4. package/public/assets/{MessageBubble-CIDyX8f7.js.map → MessageBubble-C4AeEHdz.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BmWB81w_.js → PlanGraphPanel-cwPUGFXA.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BmWB81w_.js.map → PlanGraphPanel-cwPUGFXA.js.map} +1 -1
  7. package/public/assets/{activity-QQikHZe4.js → activity-BWGwnSg5.js} +2 -2
  8. package/public/assets/{activity-QQikHZe4.js.map → activity-BWGwnSg5.js.map} +1 -1
  9. package/public/assets/{agent-profiles-WvAN5cR_.js → agent-profiles-C6N9cTJg.js} +2 -2
  10. package/public/assets/{agent-profiles-WvAN5cR_.js.map → agent-profiles-C6N9cTJg.js.map} +1 -1
  11. package/public/assets/{agents-BsFfumLn.js → agents-FUBxhCEU.js} +2 -2
  12. package/public/assets/{agents-BsFfumLn.js.map → agents-FUBxhCEU.js.map} +1 -1
  13. package/public/assets/{automation-BTbpiINE.js → automation-CMketzfB.js} +2 -2
  14. package/public/assets/{automation-BTbpiINE.js.map → automation-CMketzfB.js.map} +1 -1
  15. package/public/assets/{chat-BlFUmD3r.js → chat-BV_pnRhk.js} +2 -2
  16. package/public/assets/{chat-BlFUmD3r.js.map → chat-BV_pnRhk.js.map} +1 -1
  17. package/public/assets/{formatted-body-impl-Dbjjdcu9.js → formatted-body-impl-DzHQTBmn.js} +2 -2
  18. package/public/assets/{formatted-body-impl-Dbjjdcu9.js.map → formatted-body-impl-DzHQTBmn.js.map} +1 -1
  19. package/public/assets/index-CYLrIuJT.css +2 -0
  20. package/public/assets/{index-CPXz74W5.js → index-CdEAo41I.js} +7 -7
  21. package/public/assets/index-CdEAo41I.js.map +1 -0
  22. package/public/assets/{insights-D1Oq25BL.js → insights-C_pJNcFD.js} +2 -2
  23. package/public/assets/{insights-D1Oq25BL.js.map → insights-C_pJNcFD.js.map} +1 -1
  24. package/public/assets/{maintenance-CgXJwFTo.js → maintenance-Ckz5BAor.js} +2 -2
  25. package/public/assets/{maintenance-CgXJwFTo.js.map → maintenance-Ckz5BAor.js.map} +1 -1
  26. package/public/assets/{managed-agents-CbuFHwWE.js → managed-agents-BbZclnqP.js} +2 -2
  27. package/public/assets/{managed-agents-CbuFHwWE.js.map → managed-agents-BbZclnqP.js.map} +1 -1
  28. package/public/assets/{markdown-preview-impl-B79bSsKK.js → markdown-preview-impl-CtuHw6Mm.js} +2 -2
  29. package/public/assets/{markdown-preview-impl-B79bSsKK.js.map → markdown-preview-impl-CtuHw6Mm.js.map} +1 -1
  30. package/public/assets/{memory-Box_rMj_.js → memory-C5DK3rhW.js} +2 -2
  31. package/public/assets/{memory-Box_rMj_.js.map → memory-C5DK3rhW.js.map} +1 -1
  32. package/public/assets/{messages-BXIHBaN2.js → messages-CsjtiFEb.js} +2 -2
  33. package/public/assets/{messages-BXIHBaN2.js.map → messages-CsjtiFEb.js.map} +1 -1
  34. package/public/assets/{orchestrators-MNj_mp3U.js → orchestrators-D9fMiBRy.js} +2 -2
  35. package/public/assets/{orchestrators-MNj_mp3U.js.map → orchestrators-D9fMiBRy.js.map} +1 -1
  36. package/public/assets/{overview-KfwJgCUa.js → overview-BLWoCnNQ.js} +2 -2
  37. package/public/assets/{overview-KfwJgCUa.js.map → overview-BLWoCnNQ.js.map} +1 -1
  38. package/public/assets/{pairs-DfrGWlgL.js → pairs-HnvpLz4C.js} +2 -2
  39. package/public/assets/{pairs-DfrGWlgL.js.map → pairs-HnvpLz4C.js.map} +1 -1
  40. package/public/assets/{registry-CiBC97sL.js → registry-BqAS3ADV.js} +2 -2
  41. package/public/assets/{registry-CiBC97sL.js.map → registry-BqAS3ADV.js.map} +1 -1
  42. package/public/assets/{security-BbU_vIdb.js → security-CWgtcQ2X.js} +2 -2
  43. package/public/assets/{security-BbU_vIdb.js.map → security-CWgtcQ2X.js.map} +1 -1
  44. package/public/assets/{select-CeQwmVTm.js → select-UdIYiKFs.js} +2 -2
  45. package/public/assets/{select-CeQwmVTm.js.map → select-UdIYiKFs.js.map} +1 -1
  46. package/public/assets/settings-ruDxPuza.js +6 -0
  47. package/public/assets/settings-ruDxPuza.js.map +1 -0
  48. package/public/assets/{tasks-za0RxIQw.js → tasks-DomQFwZP.js} +2 -2
  49. package/public/assets/{tasks-za0RxIQw.js.map → tasks-DomQFwZP.js.map} +1 -1
  50. package/public/assets/{teams-ByAqwLG9.js → teams-CXNp0U3B.js} +2 -2
  51. package/public/assets/{teams-ByAqwLG9.js.map → teams-CXNp0U3B.js.map} +1 -1
  52. package/public/assets/{terminal-viewer-impl-CkoWD5D_.js → terminal-viewer-impl-DiWBtzvZ.js} +2 -2
  53. package/public/assets/{terminal-viewer-impl-CkoWD5D_.js.map → terminal-viewer-impl-DiWBtzvZ.js.map} +1 -1
  54. package/public/assets/{user-avatar-Q_N6iHEn.js → user-avatar-DBs9Koqp.js} +2 -2
  55. package/public/assets/{user-avatar-Q_N6iHEn.js.map → user-avatar-DBs9Koqp.js.map} +1 -1
  56. package/public/assets/{work-queue-DfRo1B2P.js → work-queue-BD29QuLd.js} +2 -2
  57. package/public/assets/{work-queue-DfRo1B2P.js.map → work-queue-BD29QuLd.js.map} +1 -1
  58. package/public/index.html +2 -2
  59. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  60. package/runner/src/adapter.ts +9 -1
  61. package/src/agent-busy-health.ts +62 -63
  62. package/src/codex-wham-quota.ts +5 -76
  63. package/src/compaction-watch.ts +7 -6
  64. package/src/db/provider-quota-burn.ts +6 -18
  65. package/src/db/provider-quotas.ts +27 -12
  66. package/src/lifecycle-manager.ts +4 -2
  67. package/src/maintenance/jobs.ts +1 -1
  68. package/src/notification-types.ts +3 -1
  69. package/src/quota.ts +1 -1
  70. package/src/routes/index.ts +3 -0
  71. package/src/routes/routing-policy.ts +25 -0
  72. package/src/routing-policy-push.ts +82 -0
  73. package/src/routing-policy-store.ts +148 -0
  74. package/src/routing-rule-resolver.ts +87 -0
  75. package/src/security.ts +6 -0
  76. package/src/spawn-targets.ts +44 -12
  77. package/public/assets/index-CPWbr4sq.css +0 -2
  78. package/public/assets/index-CPXz74W5.js.map +0 -1
  79. package/public/assets/settings-DBsL3jb7.js +0 -6
  80. package/public/assets/settings-DBsL3jb7.js.map +0 -1
@@ -10,6 +10,7 @@ const PROVIDER_QUOTA_STALE_AFTER_MS = 10 * 60_000;
10
10
  const QUOTA_UTILIZATION_EPSILON = 1e-9;
11
11
  const QUOTA_RESET_BOUNDARY_ADVANCE_MS = 5_000;
12
12
  const QUOTA_RESET_UTILIZATION_MAX = 0.01;
13
+ const FIVE_HOUR_MS = 5 * 60 * 60_000;
13
14
 
14
15
  export interface ProviderQuotaAdvisoryInput {
15
16
  provider: string;
@@ -258,11 +259,20 @@ function quotaWindowUtilizationRegressed(existing: QuotaState["windows"][number]
258
259
  && !quotaWindowLooksReset(existing, incoming);
259
260
  }
260
261
 
261
- function publicQuotaState(quota: StoredQuotaState): QuotaState {
262
+ function publicQuotaState(quota: StoredQuotaState, now?: number): QuotaState {
263
+ let emulated = false;
264
+ const windows = quota.windows.map((window) => {
265
+ const period = quotaWindowPeriodMs(window.name);
266
+ if (now === undefined || period === undefined || typeof window.resetsAt !== "number" || !Number.isFinite(window.resetsAt) || now < window.resetsAt) return { ...window };
267
+ let resetsAt = window.resetsAt;
268
+ while (now >= resetsAt) resetsAt += period;
269
+ emulated = true;
270
+ return { ...window, utilization: 0, resetsAt };
271
+ });
262
272
  return {
263
- windows: quota.windows.map((window) => ({ ...window })),
264
- source: quota.source,
265
- updatedAt: quota.updatedAt,
273
+ windows,
274
+ source: emulated ? "emulated" : quota.source,
275
+ updatedAt: emulated && now !== undefined ? Math.max(quota.updatedAt, now) : quota.updatedAt,
266
276
  };
267
277
  }
268
278
 
@@ -287,7 +297,7 @@ function storedQuotaWindowSampleTimes(quota: StoredQuotaState): Map<string, numb
287
297
  }
288
298
 
289
299
  // Source priority for per-window precedence: "ingest" (WHAM/API) beats "probe" (agent self-report).
290
- const QUOTA_SOURCE_PRIORITY: Record<string, number> = { ingest: 3, header: 2, probe: 1, statusline: 0 };
300
+ const QUOTA_SOURCE_PRIORITY: Record<string, number> = { ingest: 3, header: 2, probe: 1, statusline: 0, emulated: -1 };
291
301
  function quotaSourcePriority(source: string): number {
292
302
  return QUOTA_SOURCE_PRIORITY[source] ?? 1;
293
303
  }
@@ -345,6 +355,12 @@ function quotaWindowSampleTimes(provider: string, accountKey: string, fallback?:
345
355
  return times;
346
356
  }
347
357
 
358
+ function quotaWindowPeriodMs(name: string): number | undefined {
359
+ if (name === "five_hour" || name === "primary") return FIVE_HOUR_MS;
360
+ if (name === "seven_day" || name === "seven_day_sonnet" || name === "seven_day_opus" || name === "secondary") return 7 * DAY_MS;
361
+ return undefined;
362
+ }
363
+
348
364
  function mergeQuotaWindows(existing: StoredQuotaState | undefined, incoming: QuotaState, provider: string, accountKey: string): {
349
365
  quota: StoredQuotaState;
350
366
  acceptedIncomingWindow: boolean;
@@ -370,12 +386,11 @@ function mergeQuotaWindows(existing: StoredQuotaState | undefined, incoming: Quo
370
386
  if (!incomingWindow) {
371
387
  mergedTimes.set(key, existingUpdatedAt); mergedSources.set(key, existingSource); return window;
372
388
  }
373
- // Per-window source precedence is symmetric in BOTH directions (#712, #716):
374
- // - A higher-priority existing value is kept even against a newer lower-priority reading.
375
- // - A higher-priority incoming value (e.g. authoritative WHAM `ingest`) overrides the stored
376
- // value outright, bypassing the timestamp + regression guards below. Those guards exist to
377
- // reject a noisy lower-or-equal source's improbable swing (a wedged probe self-report) they
378
- // must NOT block authoritative data correcting a stale probe that pinned the window high.
389
+ // Per-window source precedence: #712/#716 are handled by the symmetric three-way split below.
390
+ // Higher existing keep (no regression check needed; lower-priority input simply loses).
391
+ // Higher incoming accept (no regression check needed; higher-priority source is authoritative).
392
+ // Same priority → apply the regression guard (rejects improbable utilization drops from noisy
393
+ // same-quality sources, e.g. a wedged probe self-report; #725 §4.3).
379
394
  const existingPriority = quotaSourcePriority(existingSource);
380
395
  const incomingPriority = quotaSourcePriority(incoming.source);
381
396
  if (existingPriority > incomingPriority) {
@@ -435,7 +450,7 @@ function rowToSnapshot(row: ProviderQuotaSnapshotRow): ProviderQuotaSnapshot {
435
450
  function rowToRecord(row: ProviderQuotaRow, history: ProviderQuotaSnapshot[], now: number): ProviderQuotaRecord {
436
451
  const lastError = parseJson<ProviderQuotaError | undefined>(row.last_error, undefined);
437
452
  const lastUpdatedAt = row.last_updated_at ?? undefined;
438
- const quota = row.quota_state ? publicQuotaState(parseStoredQuotaState(row.quota_state, { windows: [], source: "probe", updatedAt: lastUpdatedAt ?? row.last_attempt_at })) : undefined;
453
+ const quota = row.quota_state ? publicQuotaState(parseStoredQuotaState(row.quota_state, { windows: [], source: "probe", updatedAt: lastUpdatedAt ?? row.last_attempt_at }), now) : undefined;
439
454
  const burn = quota ? deriveProviderQuotaBurn({
440
455
  provider: row.provider,
441
456
  quota,
@@ -622,8 +622,10 @@ export function __resetLifecycleManager(): void {
622
622
  singleton = null;
623
623
  }
624
624
 
625
- function managedModelUnavailableMessage(policy: SpawnPolicy, error: string, exited?: ManagedSessionExitDiagnostics): string | null {
626
- if (policy.provider !== "claude" && exited?.provider !== "claude") return null;
625
+ function managedModelUnavailableMessage(_policy: SpawnPolicy, error: string, exited?: ManagedSessionExitDiagnostics): string | null {
626
+ // extractClaudeModelUnavailableMessage returns null for text that doesn't match
627
+ // the Claude-specific error pattern, so this is safe to call unconditionally —
628
+ // no provider=== branch needed here; the parser is the gate (#743).
627
629
  return extractClaudeModelUnavailableMessage([
628
630
  error,
629
631
  exited?.lastError,
@@ -83,7 +83,7 @@ function isManagedBusyWatchCandidate(agent: ReturnType<typeof listAgents>[number
83
83
 
84
84
  function stuckBusyBody(agentId: string, health: NonNullable<ReturnType<typeof deriveAgentBusyHealth>>): string {
85
85
  const staleMinutes = Math.max(1, Math.floor(health.staleForMs / 60_000));
86
- const work = health.activeWorkLabel || health.activeWorkKind || health.activeWorkId || health.kind;
86
+ const work = health.activeWorkLabel || health.activeWorkId || health.kind;
87
87
  return `Agent \`${agentId}\` appears stuck busy in ${work} for ${staleMinutes} min with no provider progress. Use interrupt first if available; restart or shutdown if the turn is wedged.`;
88
88
  }
89
89
 
@@ -25,7 +25,8 @@ export type NotificationType =
25
25
  | "workspace.stranded_escalation"
26
26
  | "workspace.auto_abandoned"
27
27
  | "workspace.review_request"
28
- | "workspace.review_changes_requested";
28
+ | "workspace.review_changes_requested"
29
+ | "routing.policy.changed";
29
30
 
30
31
  /**
31
32
  * The entities a notification is ABOUT: routing inputs (#442) and explainability subjects (#446).
@@ -94,6 +95,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
94
95
  "workspace.auto_abandoned": null,
95
96
  "workspace.review_request": null,
96
97
  "workspace.review_changes_requested": null,
98
+ "routing.policy.changed": 15 * MINUTES,
97
99
  };
98
100
 
99
101
  export const NOTIFICATION_TYPES = Object.keys(DEFAULT_TTL_MS) as NotificationType[];
package/src/quota.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
2
  import type { ProviderQuotaError, QuotaState, QuotaWindowName, QuotaSource, QuotaUnit } from "./types";
3
3
 
4
- const QUOTA_SOURCES = new Set<QuotaSource>(["header", "probe", "ingest", "statusline"]);
4
+ const QUOTA_SOURCES = new Set<QuotaSource>(["header", "probe", "ingest", "statusline", "emulated"]);
5
5
  const QUOTA_UNITS = new Set<QuotaUnit>(["tokens", "requests", "%"]);
6
6
  const QUOTA_WINDOWS = new Set<QuotaWindowName>(["five_hour", "seven_day", "seven_day_sonnet", "seven_day_opus", "primary", "secondary"]);
7
7
 
@@ -8,6 +8,7 @@ import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomati
8
8
  import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
9
9
  import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaPublisherLeaseAcquireRoute, postProviderQuotaPublisherLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
10
10
  import { getProviderQuotaConfigRoute, getProviderQuotaConfigsRoute, putProviderQuotaConfigRoute } from "./provider-quota-config";
11
+ import { getRoutingPolicyRoute, putRoutingPolicyRoute } from "./routing-policy";
11
12
  import { deleteProvisioningCapabilityRoute, getProvisioningCapabilitiesRoute, getProvisioningCapabilityRoute, patchProvisioningCapabilityRoute, patchProvisioningVariantRoute, postProvisioningCapabilityRoute, postProvisioningVariantImportRoute, postProvisioningVariantMcpRoute, postProvisioningVariantUpdateRoute, postProvisioningVariantValidateRoute } from "./provisioning-registry";
12
13
  import { getTeamsConfigRoute, putTeamsConfigRoute } from "./teams-config";
13
14
  import { deleteConfigKey, getConfigKey, getConfigKeyHistory, getConfigNamespace, putConfigKey } from "./config";
@@ -281,6 +282,8 @@ const routes: Route[] = [
281
282
  route("GET", "/api/providers/quota-config", getProviderQuotaConfigsRoute),
282
283
  route("GET", "/api/providers/:provider/quota-config", getProviderQuotaConfigRoute),
283
284
  route("PUT", "/api/providers/:provider/quota-config", putProviderQuotaConfigRoute),
285
+ route("GET", "/api/routing-policy", getRoutingPolicyRoute),
286
+ route("PUT", "/api/routing-policy", putRoutingPolicyRoute),
284
287
  route("PUT", "/api/providers/:provider/models/:alias", putProviderModelRoute),
285
288
  route("PATCH", "/api/providers/:provider/models/:alias", patchProviderModelRoute),
286
289
  route("DELETE", "/api/providers/:provider/models/:alias", deleteProviderModelRoute),
@@ -0,0 +1,25 @@
1
+ // Routing-policy routes (#728). GET/PUT for the relay-owned routing-policy document.
2
+ // GET: agent:read (coordinators read the policy to apply rules).
3
+ // PUT: settings:write:routing (admin writes).
4
+ import { ValidationError } from "../db";
5
+ import { error, json, parseBody, type Handler } from "./_shared";
6
+ import { getRoutingPolicy, setRoutingPolicy } from "../routing-policy-store";
7
+ import { isRecord } from "agent-relay-sdk";
8
+ import { cleanString } from "../validation";
9
+
10
+ export const getRoutingPolicyRoute: Handler = () => {
11
+ return json(getRoutingPolicy());
12
+ };
13
+
14
+ export const putRoutingPolicyRoute: Handler = async (req) => {
15
+ const parsed = await parseBody<unknown>(req);
16
+ if (!parsed.ok) return error(parsed.error, parsed.status);
17
+ try {
18
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
19
+ const policy = setRoutingPolicy(parsed.body, { updatedBy });
20
+ return json(policy);
21
+ } catch (e) {
22
+ if (e instanceof ValidationError) return error(e.message, 400);
23
+ throw e;
24
+ }
25
+ };
@@ -0,0 +1,82 @@
1
+ // Live delta push for routing-policy saves (#732). When the policy is updated via
2
+ // PUT /api/routing-policy, all live spawn-capable agents (coordinators) receive an
3
+ // in-band routing.policy.changed notification so the change takes effect mid-session
4
+ // without respawn.
5
+ //
6
+ // Delivery mechanism: same emitRelayEvent + routeNotification path as agent.ready /
7
+ // agent.exited (#308/#239). emitRelayEvent writes to the durable bus outbox (so bus
8
+ // clients subscribed to routing.* receive it immediately); routeNotification sends a
9
+ // message.new push to each spawn-capable agent's inbox so connected coordinators
10
+ // that are subscribed only to message.* are also woken with the delta in-band.
11
+ //
12
+ // Spawn-capable = live token carries command:spawn (or command:* / admin:* / *).
13
+ // The same token-scope check used by quota-advisory.ts (isSpawnCapableAgent).
14
+ import { getDb } from "./db";
15
+ import { emitRelayEvent } from "./events";
16
+ import { routeNotification } from "./notification-router";
17
+ import { parseJson } from "./utils";
18
+ import { isRecord } from "agent-relay-sdk";
19
+ import type { RoutingPolicy } from "agent-relay-sdk";
20
+
21
+ const PUSH_TTL_MS = 15 * 60 * 1000;
22
+
23
+ /**
24
+ * Emit a durable `routing.policy.changed` event and push the delta to every
25
+ * live spawn-capable agent so coordinators ingest the new policy mid-session.
26
+ * Called by setRoutingPolicy on every successful save.
27
+ */
28
+ export function notifyRoutingPolicyChanged(policy: RoutingPolicy, opts: { updatedBy?: string } = {}): void {
29
+ emitRelayEvent({
30
+ type: "routing.policy.changed",
31
+ source: "server",
32
+ subject: "routing-policy:default",
33
+ data: { policy: policy as unknown as Record<string, unknown>, updatedBy: opts.updatedBy ?? null },
34
+ });
35
+
36
+ const recipients = getSpawnCapableAgentIds();
37
+ if (recipients.length === 0) return;
38
+
39
+ routeNotification({
40
+ type: "routing.policy.changed",
41
+ scope: {},
42
+ recipients,
43
+ ttlMs: PUSH_TTL_MS,
44
+ message: {
45
+ subject: "Routing policy updated",
46
+ body: `🗺 Routing policy updated${opts.updatedBy ? ` by ${opts.updatedBy}` : ""}. Ingest this delta now — spawn routing decisions take effect immediately without respawn. Re-read relay_spawn_targets for live provider/model options.`,
47
+ payload: { kind: "routing.policy.changed", policy: policy as unknown as Record<string, unknown>, updatedBy: opts.updatedBy ?? null },
48
+ replyExpected: false,
49
+ },
50
+ });
51
+ }
52
+
53
+ type AgentRow = { id: string; meta: string | null };
54
+
55
+ function getSpawnCapableAgentIds(): string[] {
56
+ const now = Date.now();
57
+ const candidates = getDb().query(`
58
+ SELECT id, meta FROM agents
59
+ WHERE ready = 1 AND status NOT IN ('offline', 'stale')
60
+ `).all() as AgentRow[];
61
+ return candidates.filter((agent) => isSpawnCapable(agent, now)).map((a) => a.id);
62
+ }
63
+
64
+ function isSpawnCapable(agent: AgentRow, now: number): boolean {
65
+ const meta = parseJson<Record<string, unknown>>(agent.meta ?? "{}", {});
66
+ const auth = isRecord(meta.auth) ? meta.auth : undefined;
67
+ const jti = typeof auth?.jti === "string" ? auth.jti : undefined;
68
+ if (!jti) return false;
69
+ const row = getDb().query(
70
+ "SELECT scope, revoked_at, expires_at FROM tokens WHERE jti = ?",
71
+ ).get(jti) as { scope: string; revoked_at?: number | null; expires_at?: number | null } | undefined;
72
+ if (!row || row.revoked_at) return false;
73
+ const nowSeconds = Math.floor(now / 1000);
74
+ if (row.expires_at !== null && row.expires_at !== undefined && row.expires_at <= nowSeconds) return false;
75
+ const scopes = parseJson<string[]>(row.scope, []);
76
+ return (
77
+ scopes.includes("command:spawn") ||
78
+ scopes.includes("command:*") ||
79
+ scopes.includes("admin:*") ||
80
+ scopes.includes("*")
81
+ );
82
+ }
@@ -0,0 +1,148 @@
1
+ // Routing-policy storage (#728). One relay-owned policy document per instance,
2
+ // backed by the existing `config` table (namespace "routing-policy", key "default")
3
+ // so history + config.changed SSE come for free at no schema cost.
4
+ //
5
+ // The policy covers routing rules (task-type/capability → provider/model/effort)
6
+ // and quota tuning (band thresholds + per-provider hardBackstopUtilization /
7
+ // burnPenaltyUtilizationFloor). #730 will wire quota fields into spawn-targets.ts;
8
+ // this module only stores and retrieves them.
9
+ import { ValidationError } from "./db";
10
+ import { getConfig, setConfig } from "./config-store";
11
+ import { notifyRoutingPolicyChanged } from "./routing-policy-push";
12
+ import { isRecord, SPAWN_PROVIDERS } from "agent-relay-sdk";
13
+ import type { ProviderQuotaRoutingConfig, QuotaBandThresholds, RoutingPolicy, RoutingRule } from "agent-relay-sdk";
14
+
15
+ export const ROUTING_POLICY_NAMESPACE = "routing-policy";
16
+ export const ROUTING_POLICY_KEY = "default";
17
+
18
+ /** Default policy — preserves current spawn-targets.ts behaviour when unset. */
19
+ export const DEFAULT_ROUTING_POLICY: RoutingPolicy = {
20
+ schemaVersion: 1,
21
+ rules: [],
22
+ };
23
+
24
+ /** Retrieve the stored routing policy, falling back to the default when absent. */
25
+ export function getRoutingPolicy(): RoutingPolicy {
26
+ const entry = getConfig<RoutingPolicy>(ROUTING_POLICY_NAMESPACE, ROUTING_POLICY_KEY);
27
+ return entry ? normalizeRoutingPolicy(entry.value) : { ...DEFAULT_ROUTING_POLICY };
28
+ }
29
+
30
+ /**
31
+ * Persist the routing policy. The incoming value is normalized so partial bodies
32
+ * still produce a valid document. Emits `config.changed` via `setConfig` so the
33
+ * dashboard + any live listeners pick it up immediately.
34
+ */
35
+ export function setRoutingPolicy(value: unknown, opts: { updatedBy?: string } = {}): RoutingPolicy {
36
+ const normalized = normalizeRoutingPolicy(value);
37
+ setConfig(ROUTING_POLICY_NAMESPACE, ROUTING_POLICY_KEY, normalized, opts.updatedBy);
38
+ notifyRoutingPolicyChanged(normalized, opts);
39
+ return normalized;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Normalizer — coerces arbitrary input into a valid RoutingPolicy, defaulting
44
+ // missing fields so a partial PUT is safe and a missing policy is a no-op.
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export function normalizeRoutingPolicy(value: unknown): RoutingPolicy {
48
+ const input = isRecord(value) ? value : {};
49
+ return {
50
+ schemaVersion: 1,
51
+ rules: normalizeRules(input.rules),
52
+ ...(input.quotaBands !== undefined ? { quotaBands: normalizeQuotaBands(input.quotaBands) } : {}),
53
+ ...(input.providerQuota !== undefined ? { providerQuota: normalizeProviderQuota(input.providerQuota) } : {}),
54
+ };
55
+ }
56
+
57
+ function normalizeRules(raw: unknown): RoutingRule[] {
58
+ if (!Array.isArray(raw)) return [];
59
+ const rules: RoutingRule[] = [];
60
+ for (let i = 0; i < raw.length; i++) {
61
+ rules.push(normalizeRule(raw[i], i));
62
+ }
63
+ return rules;
64
+ }
65
+
66
+ function normalizeRule(raw: unknown, idx: number): RoutingRule {
67
+ if (!isRecord(raw)) throw new ValidationError(`rules[${idx}] must be an object`);
68
+ const match = isRecord(raw.match) ? raw.match : {};
69
+ if (!isRecord(raw.match)) throw new ValidationError(`rules[${idx}].match must be an object`);
70
+ if (match.taskType === undefined && match.capability === undefined) {
71
+ throw new ValidationError(`rules[${idx}].match must set at least one of: taskType, capability`);
72
+ }
73
+ const target = isRecord(raw.target) ? raw.target : {};
74
+ if (!isRecord(raw.target)) throw new ValidationError(`rules[${idx}].target must be an object`);
75
+ if (typeof target.provider !== "string" || !(SPAWN_PROVIDERS as readonly string[]).includes(target.provider)) {
76
+ throw new ValidationError(`rules[${idx}].target.provider must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
77
+ }
78
+ const rule: RoutingRule = {
79
+ match: {
80
+ ...(typeof match.taskType === "string" && match.taskType ? { taskType: match.taskType } : {}),
81
+ ...(typeof match.capability === "string" && match.capability ? { capability: match.capability } : {}),
82
+ },
83
+ target: {
84
+ provider: target.provider as RoutingRule["target"]["provider"],
85
+ ...(typeof target.model === "string" && target.model ? { model: target.model } : {}),
86
+ ...(typeof target.effort === "string" && target.effort ? { effort: target.effort } : {}),
87
+ },
88
+ };
89
+ if (typeof raw.label === "string" && raw.label) rule.label = raw.label;
90
+ if (typeof raw.enabled === "boolean") rule.enabled = raw.enabled;
91
+ return rule;
92
+ }
93
+
94
+ function normalizeQuotaBands(raw: unknown): Partial<QuotaBandThresholds> {
95
+ if (!isRecord(raw)) throw new ValidationError("quotaBands must be an object");
96
+ const result: Partial<QuotaBandThresholds> = {};
97
+ if (raw.cautionAt !== undefined) result.cautionAt = cleanQuotaFraction(raw.cautionAt, "quotaBands.cautionAt");
98
+ if (raw.avoidLargeAt !== undefined) result.avoidLargeAt = cleanQuotaFraction(raw.avoidLargeAt, "quotaBands.avoidLargeAt");
99
+ if (raw.hardAvoidAt !== undefined) result.hardAvoidAt = cleanQuotaFraction(raw.hardAvoidAt, "quotaBands.hardAvoidAt");
100
+ const orderedPairs: Array<[string, number]> = [];
101
+ if (result.cautionAt !== undefined) orderedPairs.push(["cautionAt", result.cautionAt]);
102
+ if (result.avoidLargeAt !== undefined) orderedPairs.push(["avoidLargeAt", result.avoidLargeAt]);
103
+ if (result.hardAvoidAt !== undefined) orderedPairs.push(["hardAvoidAt", result.hardAvoidAt]);
104
+ for (let i = 0; i < orderedPairs.length - 1; i++) {
105
+ const cur = orderedPairs[i]!;
106
+ const next = orderedPairs[i + 1]!;
107
+ if (cur[1] >= next[1]) {
108
+ throw new ValidationError(`quotaBands.${cur[0]} must be less than quotaBands.${next[0]}`);
109
+ }
110
+ }
111
+ return result;
112
+ }
113
+
114
+ function normalizeProviderQuota(raw: unknown): Partial<Record<string, ProviderQuotaRoutingConfig>> {
115
+ if (!isRecord(raw)) throw new ValidationError("providerQuota must be an object");
116
+ const result: Partial<Record<string, ProviderQuotaRoutingConfig>> = {};
117
+ for (const provider of SPAWN_PROVIDERS) {
118
+ if (!(provider in raw)) continue;
119
+ result[provider] = normalizeProviderQuotaConfig(raw[provider], provider);
120
+ }
121
+ return result;
122
+ }
123
+
124
+ function normalizeProviderQuotaConfig(raw: unknown, provider: string): ProviderQuotaRoutingConfig {
125
+ if (!isRecord(raw)) throw new ValidationError(`providerQuota.${provider} must be an object`);
126
+ const cfg: ProviderQuotaRoutingConfig = {};
127
+ if (raw.hardBackstopUtilization !== undefined) {
128
+ cfg.hardBackstopUtilization = cleanFraction(raw.hardBackstopUtilization, `providerQuota.${provider}.hardBackstopUtilization`);
129
+ }
130
+ if (raw.burnPenaltyUtilizationFloor !== undefined) {
131
+ cfg.burnPenaltyUtilizationFloor = cleanFraction(raw.burnPenaltyUtilizationFloor, `providerQuota.${provider}.burnPenaltyUtilizationFloor`);
132
+ }
133
+ return cfg;
134
+ }
135
+
136
+ function cleanQuotaFraction(value: unknown, field: string): number {
137
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
138
+ throw new ValidationError(`${field} must be a non-negative finite number`);
139
+ }
140
+ return value;
141
+ }
142
+
143
+ function cleanFraction(value: unknown, field: string): number {
144
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
145
+ throw new ValidationError(`${field} must be a number between 0 and 1`);
146
+ }
147
+ return value;
148
+ }
@@ -0,0 +1,87 @@
1
+ // Declarative routing-rules engine (#729).
2
+ //
3
+ // Resolves the first matching enabled rule from RoutingPolicy.rules, given a task-type
4
+ // and/or capability. Rules are ADVISORY — they surface a recommended target to the
5
+ // coordinator; they never override an explicit per-spawn argument or block a spawn.
6
+ //
7
+ // Precedence (highest → lowest):
8
+ // 1. Explicit per-spawn args (provider/model/effort in relay_spawn_agent / SpawnAgentInput).
9
+ // 2. This resolver — the recommendation when no explicit arg is given.
10
+ // 3. Profile default — fallback when no rule matches.
11
+ //
12
+ // The quota band / gate is a parallel SAFETY layer that composes on top of the resolved
13
+ // target (it may advise a cheaper effort or healthier provider; a hard block still blocks).
14
+ // It is NOT part of rule resolution and is NOT consulted here.
15
+ import type { RoutingRule } from "agent-relay-sdk";
16
+
17
+ /** Input criteria for rule matching. At least one field should be set. */
18
+ export interface RoutingMatchInput {
19
+ /** Task-type to match against rule.match.taskType (prefix semantics). */
20
+ taskType?: string;
21
+ /** Capability tag to match against rule.match.capability (exact match). */
22
+ capability?: string;
23
+ }
24
+
25
+ /**
26
+ * The resolved target recommendation from a matching rule.
27
+ * Mirrors RoutingRule["target"] so callers never import the full rule.
28
+ */
29
+ export interface RoutingResolution {
30
+ /** Recommended provider. */
31
+ provider: RoutingRule["target"]["provider"];
32
+ /** Recommended model alias, if the rule specifies one. */
33
+ model?: string;
34
+ /** Recommended effort level, if the rule specifies one. */
35
+ effort?: string;
36
+ /** Human-readable label from the matched rule, for logging/display. */
37
+ matchedRuleLabel?: string;
38
+ }
39
+
40
+ /**
41
+ * Evaluate the rules array in order and return the first matching enabled rule's target.
42
+ *
43
+ * Match semantics:
44
+ * - `taskType`: the rule's taskType is a prefix of the input (e.g. rule "gui" matches
45
+ * input "gui" and "gui/ux", but not "gui2" or "xgui"). Case-sensitive.
46
+ * - `capability`: exact match. Case-sensitive.
47
+ * - When a rule sets both taskType AND capability, both must match (AND semantics).
48
+ * - A rule with `enabled: false` is skipped.
49
+ *
50
+ * Returns null when no rule matches or the rules array is empty.
51
+ */
52
+ export function resolveRoutingRule(
53
+ rules: RoutingRule[],
54
+ input: RoutingMatchInput,
55
+ ): RoutingResolution | null {
56
+ for (const rule of rules) {
57
+ if (rule.enabled === false) continue;
58
+ if (!matchesCriteria(rule.match, input)) continue;
59
+ return {
60
+ provider: rule.target.provider,
61
+ ...(rule.target.model ? { model: rule.target.model } : {}),
62
+ ...(rule.target.effort ? { effort: rule.target.effort } : {}),
63
+ ...(rule.label ? { matchedRuleLabel: rule.label } : {}),
64
+ };
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function matchesCriteria(criteria: RoutingRule["match"], input: RoutingMatchInput): boolean {
70
+ if (!criteria.taskType && !criteria.capability) return false;
71
+ if (criteria.taskType !== undefined) {
72
+ if (!input.taskType) return false;
73
+ if (!isPrefixMatch(input.taskType, criteria.taskType)) return false;
74
+ }
75
+ if (criteria.capability !== undefined) {
76
+ if (input.capability !== criteria.capability) return false;
77
+ }
78
+ return true;
79
+ }
80
+
81
+ /**
82
+ * True if `value` equals `prefix` or starts with `prefix + "/"`.
83
+ * Guards against substring-only matches (e.g. "gui" must not match "gui2").
84
+ */
85
+ function isPrefixMatch(value: string, prefix: string): boolean {
86
+ return value === prefix || value.startsWith(prefix + "/");
87
+ }
package/src/security.ts CHANGED
@@ -199,6 +199,7 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
199
199
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
200
200
  if (pathname === "/api/provider-quota-leases/acquire" || pathname === "/api/provider-quota-leases/release") return "quota:write";
201
201
  if (pathname === "/api/provider-quotas") return method === "GET" ? "agent:read" : "quota:write";
202
+ if (pathname === "/api/routing-policy") return method === "GET" ? "agent:read" : "settings:write:routing";
202
203
  if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
203
204
  if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
204
205
  if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
@@ -288,6 +289,11 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
288
289
  if (pathname === "/api/providers/quota-config" || pathname.match(/^\/api\/providers\/[^/]+\/quota-config$/)) {
289
290
  return method === "GET" ? "agent:read" : "settings:write:provider";
290
291
  }
292
+ // Routing policy (#728): reads are agent:read (coordinators read to apply rules);
293
+ // writes are settings:write:routing (admin-scoped).
294
+ if (pathname === "/api/routing-policy") {
295
+ return method === "GET" ? "agent:read" : "settings:write:routing";
296
+ }
291
297
  if (pathname === "/api/teams/config") return method === "GET" ? "teams:read" : "settings:write:teams";
292
298
  if (pathname.startsWith("/api/messages") || pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
293
299
  if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";