agent-relay-server 0.77.1 → 0.78.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 +79 -1
- package/package.json +2 -2
- package/src/config-store.ts +3 -1
- package/src/db/index.ts +1 -0
- package/src/db/migrations.ts +19 -0
- package/src/db/provider-quota-leases.ts +134 -0
- package/src/db/provider-quotas.ts +58 -8
- 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/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.
|
|
5
|
+
"version": "0.78.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",
|
|
@@ -729,6 +729,84 @@
|
|
|
729
729
|
]
|
|
730
730
|
}
|
|
731
731
|
},
|
|
732
|
+
"/api/orchestrators/{id}/provider-quota-leases/acquire": {
|
|
733
|
+
"post": {
|
|
734
|
+
"operationId": "postProviderQuotaLeaseAcquireRoute",
|
|
735
|
+
"summary": "Acquire provider quota poll lease",
|
|
736
|
+
"tags": [
|
|
737
|
+
"Orchestrators"
|
|
738
|
+
],
|
|
739
|
+
"description": "Orchestrator-only election endpoint. Grants or renews the single fleet-wide polling lease for one `(provider, accountKey)` when no non-expired holder exists, or returns the current holder and retry delay. The caller must renew with the returned `leaseToken`; another process with the same orchestrator id but no token cannot steal the lease.",
|
|
740
|
+
"parameters": [
|
|
741
|
+
{
|
|
742
|
+
"name": "id",
|
|
743
|
+
"in": "path",
|
|
744
|
+
"required": true,
|
|
745
|
+
"schema": {
|
|
746
|
+
"type": "string"
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
],
|
|
750
|
+
"responses": {
|
|
751
|
+
"200": {
|
|
752
|
+
"description": "Success",
|
|
753
|
+
"content": {
|
|
754
|
+
"application/json": {}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
},
|
|
758
|
+
"security": [
|
|
759
|
+
{
|
|
760
|
+
"bearerAuth": []
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
"tokenHeader": []
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
"tokenQuery": []
|
|
767
|
+
}
|
|
768
|
+
]
|
|
769
|
+
}
|
|
770
|
+
},
|
|
771
|
+
"/api/orchestrators/{id}/provider-quota-leases/release": {
|
|
772
|
+
"post": {
|
|
773
|
+
"operationId": "postProviderQuotaLeaseReleaseRoute",
|
|
774
|
+
"summary": "Release provider quota poll lease",
|
|
775
|
+
"tags": [
|
|
776
|
+
"Orchestrators"
|
|
777
|
+
],
|
|
778
|
+
"description": "Releases a provider quota poll lease only when `provider`, `accountKey`, orchestrator id, and `leaseToken` all match the current holder.",
|
|
779
|
+
"parameters": [
|
|
780
|
+
{
|
|
781
|
+
"name": "id",
|
|
782
|
+
"in": "path",
|
|
783
|
+
"required": true,
|
|
784
|
+
"schema": {
|
|
785
|
+
"type": "string"
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
],
|
|
789
|
+
"responses": {
|
|
790
|
+
"200": {
|
|
791
|
+
"description": "Success",
|
|
792
|
+
"content": {
|
|
793
|
+
"application/json": {}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
},
|
|
797
|
+
"security": [
|
|
798
|
+
{
|
|
799
|
+
"bearerAuth": []
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
"tokenHeader": []
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
"tokenQuery": []
|
|
806
|
+
}
|
|
807
|
+
]
|
|
808
|
+
}
|
|
809
|
+
},
|
|
732
810
|
"/api/agents/spawn/directories": {
|
|
733
811
|
"get": {
|
|
734
812
|
"operationId": "getHostDirectories",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.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.54",
|
|
37
37
|
"ajv": "^8.20.0"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
package/src/config-store.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getDb, ValidationError } from "./db";
|
|
2
2
|
import { cleanEnum, cleanString, cleanStringArray } from "./validation";
|
|
3
3
|
import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from "./context-threshold-config";
|
|
4
|
+
import { DEFAULT_QUOTA_THRESHOLD_CONFIG, validateQuotaThresholdConfig } from "./quota-threshold-config";
|
|
4
5
|
import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
|
|
5
6
|
import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
|
|
6
7
|
import { PROJECT_INSTRUCTION_POLICY_NAMES } from "./project-instructions";
|
|
@@ -499,7 +500,7 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
|
|
|
499
500
|
// Relay-driven lifecycle push notifications (#239 event bus). Default-on; the
|
|
500
501
|
// operator can flip the master switch or individual events off via the generic
|
|
501
502
|
// config route. Push messages wake recipients, so they must be suppressible.
|
|
502
|
-
const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = { enabled: true, branchLanded: true, agentReady: true, agentExited: true, agentSpawnFailed: true, contextThreshold: DEFAULT_CONTEXT_THRESHOLD_CONFIG };
|
|
503
|
+
const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = { enabled: true, branchLanded: true, agentReady: true, agentExited: true, agentSpawnFailed: true, contextThreshold: DEFAULT_CONTEXT_THRESHOLD_CONFIG, quotaThreshold: DEFAULT_QUOTA_THRESHOLD_CONFIG };
|
|
503
504
|
|
|
504
505
|
function validateNotificationsConfig(value: unknown): NotificationsConfig {
|
|
505
506
|
if (!isRecord(value)) throw new ValidationError("notifications config value must be an object");
|
|
@@ -512,6 +513,7 @@ function validateNotificationsConfig(value: unknown): NotificationsConfig {
|
|
|
512
513
|
agentExited: bool("agentExited"),
|
|
513
514
|
agentSpawnFailed: bool("agentSpawnFailed"),
|
|
514
515
|
contextThreshold: validateContextThresholdConfig(value.contextThreshold),
|
|
516
|
+
quotaThreshold: validateQuotaThresholdConfig(value.quotaThreshold),
|
|
515
517
|
};
|
|
516
518
|
}
|
|
517
519
|
|
package/src/db/index.ts
CHANGED
package/src/db/migrations.ts
CHANGED
|
@@ -658,6 +658,7 @@ export function applyMigrations(): void {
|
|
|
658
658
|
account_key TEXT NOT NULL,
|
|
659
659
|
quota_state TEXT,
|
|
660
660
|
source_agent_ids TEXT NOT NULL DEFAULT '[]',
|
|
661
|
+
quota_advisory_state TEXT NOT NULL DEFAULT '{}',
|
|
661
662
|
last_updated_at INTEGER,
|
|
662
663
|
last_attempt_at INTEGER NOT NULL,
|
|
663
664
|
last_error TEXT,
|
|
@@ -666,6 +667,10 @@ export function applyMigrations(): void {
|
|
|
666
667
|
PRIMARY KEY (provider, account_key)
|
|
667
668
|
)
|
|
668
669
|
`);
|
|
670
|
+
const providerQuotaCols = (getDb().query("PRAGMA table_info(provider_quotas)").all() as Array<{ name: string }>).map((col) => col.name);
|
|
671
|
+
if (!providerQuotaCols.includes("quota_advisory_state")) {
|
|
672
|
+
getDb().run("ALTER TABLE provider_quotas ADD COLUMN quota_advisory_state TEXT NOT NULL DEFAULT '{}'");
|
|
673
|
+
}
|
|
669
674
|
getDb().run(`
|
|
670
675
|
INSERT INTO provider_quotas (
|
|
671
676
|
provider, account_key, quota_state, source_agent_ids, last_updated_at,
|
|
@@ -703,6 +708,20 @@ export function applyMigrations(): void {
|
|
|
703
708
|
getDb().run("CREATE INDEX IF NOT EXISTS idx_quota_snapshots_agent_time ON quota_snapshots(agent_id, captured_at DESC)");
|
|
704
709
|
normalizeProviderQuotaAccountKeys();
|
|
705
710
|
|
|
711
|
+
getDb().run(`
|
|
712
|
+
CREATE TABLE IF NOT EXISTS provider_quota_leases (
|
|
713
|
+
provider TEXT NOT NULL,
|
|
714
|
+
account_key TEXT NOT NULL,
|
|
715
|
+
orchestrator_id TEXT NOT NULL,
|
|
716
|
+
lease_token TEXT NOT NULL,
|
|
717
|
+
expires_at INTEGER NOT NULL,
|
|
718
|
+
updated_at INTEGER NOT NULL,
|
|
719
|
+
created_at INTEGER NOT NULL,
|
|
720
|
+
PRIMARY KEY (provider, account_key)
|
|
721
|
+
)
|
|
722
|
+
`);
|
|
723
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_provider_quota_leases_expires ON provider_quota_leases(expires_at)");
|
|
724
|
+
|
|
706
725
|
getDb().run(`
|
|
707
726
|
CREATE TABLE IF NOT EXISTS memories (
|
|
708
727
|
id TEXT PRIMARY KEY,
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { getDb } from "./connection.ts";
|
|
3
|
+
|
|
4
|
+
export interface ProviderQuotaLease {
|
|
5
|
+
provider: string;
|
|
6
|
+
accountKey: string;
|
|
7
|
+
orchestratorId: string;
|
|
8
|
+
leaseToken: string;
|
|
9
|
+
expiresAt: number;
|
|
10
|
+
updatedAt: number;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ProviderQuotaLeaseAcquireResult {
|
|
15
|
+
acquired: boolean;
|
|
16
|
+
lease?: ProviderQuotaLease;
|
|
17
|
+
holder?: {
|
|
18
|
+
orchestratorId: string;
|
|
19
|
+
expiresAt: number;
|
|
20
|
+
};
|
|
21
|
+
retryAfterMs?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type ProviderQuotaLeaseRow = {
|
|
25
|
+
provider: string;
|
|
26
|
+
account_key: string;
|
|
27
|
+
orchestrator_id: string;
|
|
28
|
+
lease_token: string;
|
|
29
|
+
expires_at: number;
|
|
30
|
+
updated_at: number;
|
|
31
|
+
created_at: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function normalizeProvider(value: string): string {
|
|
35
|
+
return value.trim().toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function cleanAccountKey(value: string): string {
|
|
39
|
+
return value.trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function rowToLease(row: ProviderQuotaLeaseRow): ProviderQuotaLease {
|
|
43
|
+
return {
|
|
44
|
+
provider: row.provider,
|
|
45
|
+
accountKey: row.account_key,
|
|
46
|
+
orchestratorId: row.orchestrator_id,
|
|
47
|
+
leaseToken: row.lease_token,
|
|
48
|
+
expiresAt: row.expires_at,
|
|
49
|
+
updatedAt: row.updated_at,
|
|
50
|
+
createdAt: row.created_at,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function acquireProviderQuotaLease(input: {
|
|
55
|
+
provider: string;
|
|
56
|
+
accountKey: string;
|
|
57
|
+
orchestratorId: string;
|
|
58
|
+
leaseToken?: string;
|
|
59
|
+
ttlMs: number;
|
|
60
|
+
now?: number;
|
|
61
|
+
}): ProviderQuotaLeaseAcquireResult {
|
|
62
|
+
const provider = normalizeProvider(input.provider);
|
|
63
|
+
const accountKey = cleanAccountKey(input.accountKey);
|
|
64
|
+
const orchestratorId = input.orchestratorId.trim();
|
|
65
|
+
const ttlMs = Math.max(5_000, input.ttlMs);
|
|
66
|
+
const now = input.now ?? Date.now();
|
|
67
|
+
if (!provider) throw new Error("provider required");
|
|
68
|
+
if (!accountKey) throw new Error("accountKey required");
|
|
69
|
+
if (!orchestratorId) throw new Error("orchestratorId required");
|
|
70
|
+
|
|
71
|
+
const acquire = getDb().transaction(() => {
|
|
72
|
+
const existing = getDb().query("SELECT * FROM provider_quota_leases WHERE provider = ? AND account_key = ?")
|
|
73
|
+
.get(provider, accountKey) as ProviderQuotaLeaseRow | undefined;
|
|
74
|
+
const expired = !existing || existing.expires_at <= now;
|
|
75
|
+
const sameHolder = existing?.orchestrator_id === orchestratorId && existing.lease_token === input.leaseToken;
|
|
76
|
+
if (existing && !expired && !sameHolder) {
|
|
77
|
+
return {
|
|
78
|
+
acquired: false,
|
|
79
|
+
holder: { orchestratorId: existing.orchestrator_id, expiresAt: existing.expires_at },
|
|
80
|
+
retryAfterMs: Math.max(1_000, existing.expires_at - now),
|
|
81
|
+
} satisfies ProviderQuotaLeaseAcquireResult;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const leaseToken = sameHolder ? existing!.lease_token : randomUUID();
|
|
85
|
+
const createdAt = sameHolder ? existing!.created_at : now;
|
|
86
|
+
const expiresAt = now + ttlMs;
|
|
87
|
+
getDb().query(`
|
|
88
|
+
INSERT INTO provider_quota_leases (
|
|
89
|
+
provider, account_key, orchestrator_id, lease_token, expires_at, updated_at, created_at
|
|
90
|
+
)
|
|
91
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
92
|
+
ON CONFLICT(provider, account_key) DO UPDATE SET
|
|
93
|
+
orchestrator_id = excluded.orchestrator_id,
|
|
94
|
+
lease_token = excluded.lease_token,
|
|
95
|
+
expires_at = excluded.expires_at,
|
|
96
|
+
updated_at = excluded.updated_at
|
|
97
|
+
`).run(provider, accountKey, orchestratorId, leaseToken, expiresAt, now, createdAt);
|
|
98
|
+
return {
|
|
99
|
+
acquired: true,
|
|
100
|
+
lease: {
|
|
101
|
+
provider,
|
|
102
|
+
accountKey,
|
|
103
|
+
orchestratorId,
|
|
104
|
+
leaseToken,
|
|
105
|
+
expiresAt,
|
|
106
|
+
updatedAt: now,
|
|
107
|
+
createdAt,
|
|
108
|
+
},
|
|
109
|
+
} satisfies ProviderQuotaLeaseAcquireResult;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return acquire();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function releaseProviderQuotaLease(input: {
|
|
116
|
+
provider: string;
|
|
117
|
+
accountKey: string;
|
|
118
|
+
orchestratorId: string;
|
|
119
|
+
leaseToken: string;
|
|
120
|
+
}): boolean {
|
|
121
|
+
const provider = normalizeProvider(input.provider);
|
|
122
|
+
const accountKey = cleanAccountKey(input.accountKey);
|
|
123
|
+
const result = getDb().query(`
|
|
124
|
+
DELETE FROM provider_quota_leases
|
|
125
|
+
WHERE provider = ? AND account_key = ? AND orchestrator_id = ? AND lease_token = ?
|
|
126
|
+
`).run(provider, accountKey, input.orchestratorId.trim(), input.leaseToken);
|
|
127
|
+
return result.changes > 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function getProviderQuotaLease(provider: string, accountKey: string): ProviderQuotaLease | null {
|
|
131
|
+
const row = getDb().query("SELECT * FROM provider_quota_leases WHERE provider = ? AND account_key = ?")
|
|
132
|
+
.get(normalizeProvider(provider), cleanAccountKey(accountKey)) as ProviderQuotaLeaseRow | undefined;
|
|
133
|
+
return row ? rowToLease(row) : null;
|
|
134
|
+
}
|
|
@@ -6,11 +6,28 @@ import type { AgentCard, ProviderQuotaError, ProviderQuotaMatrix, ProviderQuotaR
|
|
|
6
6
|
|
|
7
7
|
const PROVIDER_QUOTA_STALE_AFTER_MS = 10 * 60_000;
|
|
8
8
|
|
|
9
|
+
export interface ProviderQuotaAdvisoryInput {
|
|
10
|
+
provider: string;
|
|
11
|
+
accountKey: string;
|
|
12
|
+
previousQuota?: QuotaState;
|
|
13
|
+
quota: QuotaState;
|
|
14
|
+
sourceAgentIds: string[];
|
|
15
|
+
previousAdvisoryState?: string | null;
|
|
16
|
+
now: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let providerQuotaAdvisoryHandler: ((input: ProviderQuotaAdvisoryInput) => string) | undefined;
|
|
20
|
+
|
|
21
|
+
export function setProviderQuotaAdvisoryHandler(handler: ((input: ProviderQuotaAdvisoryInput) => string) | undefined): void {
|
|
22
|
+
providerQuotaAdvisoryHandler = handler;
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
type ProviderQuotaRow = {
|
|
10
26
|
provider: string;
|
|
11
27
|
account_key: string;
|
|
12
28
|
quota_state?: string | null;
|
|
13
29
|
source_agent_ids?: string | null;
|
|
30
|
+
quota_advisory_state?: string | null;
|
|
14
31
|
last_updated_at?: number | null;
|
|
15
32
|
last_attempt_at: number;
|
|
16
33
|
last_error?: string | null;
|
|
@@ -42,6 +59,10 @@ function canonicalProviderQuotaAccountKey(provider: string, value: string): stri
|
|
|
42
59
|
: accountKey;
|
|
43
60
|
}
|
|
44
61
|
|
|
62
|
+
function unstableProviderQuotaAccountKey(accountKey: string): boolean {
|
|
63
|
+
return accountKey.startsWith("host:") || accountKey.startsWith("home:");
|
|
64
|
+
}
|
|
65
|
+
|
|
45
66
|
function sourceAgentHost(sourceAgentId: string | undefined): string | undefined {
|
|
46
67
|
if (!sourceAgentId) return undefined;
|
|
47
68
|
const row = getDb().query("SELECT machine, rig FROM agents WHERE id = ?").get(sourceAgentId) as { machine?: string | null; rig?: string | null } | undefined;
|
|
@@ -96,6 +117,13 @@ function newestLastError(rows: ProviderQuotaRow[]): string | null {
|
|
|
96
117
|
return row?.last_error ?? null;
|
|
97
118
|
}
|
|
98
119
|
|
|
120
|
+
function newestQuotaAdvisoryState(rows: ProviderQuotaRow[]): string {
|
|
121
|
+
const row = rows
|
|
122
|
+
.filter((candidate) => candidate.quota_advisory_state)
|
|
123
|
+
.sort((a, b) => b.updated_at - a.updated_at)[0];
|
|
124
|
+
return row?.quota_advisory_state ?? "{}";
|
|
125
|
+
}
|
|
126
|
+
|
|
99
127
|
function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccountKeys: string[], now: number): number {
|
|
100
128
|
const keys = [...new Set(oldAccountKeys.filter((key) => key && key !== accountKey))];
|
|
101
129
|
if (keys.length === 0) return 0;
|
|
@@ -117,15 +145,16 @@ function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccount
|
|
|
117
145
|
getDb().query(`
|
|
118
146
|
INSERT INTO provider_quotas (
|
|
119
147
|
provider, account_key, quota_state, source_agent_ids, last_updated_at,
|
|
120
|
-
last_attempt_at, last_error, updated_at, created_at
|
|
148
|
+
last_attempt_at, last_error, quota_advisory_state, updated_at, created_at
|
|
121
149
|
)
|
|
122
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
150
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
123
151
|
ON CONFLICT(provider, account_key) DO UPDATE SET
|
|
124
152
|
quota_state = excluded.quota_state,
|
|
125
153
|
source_agent_ids = excluded.source_agent_ids,
|
|
126
154
|
last_updated_at = excluded.last_updated_at,
|
|
127
155
|
last_attempt_at = excluded.last_attempt_at,
|
|
128
156
|
last_error = excluded.last_error,
|
|
157
|
+
quota_advisory_state = excluded.quota_advisory_state,
|
|
129
158
|
updated_at = excluded.updated_at
|
|
130
159
|
`).run(
|
|
131
160
|
provider,
|
|
@@ -135,6 +164,7 @@ function mergeProviderQuotaRows(provider: string, accountKey: string, oldAccount
|
|
|
135
164
|
lastUpdatedAt ?? null,
|
|
136
165
|
lastAttemptAt || now,
|
|
137
166
|
newestLastError(rows),
|
|
167
|
+
newestQuotaAdvisoryState(rows),
|
|
138
168
|
updatedAt,
|
|
139
169
|
createdAt,
|
|
140
170
|
);
|
|
@@ -251,6 +281,23 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
|
|
|
251
281
|
if (input.quota && latestSnapshotFingerprint(provider, accountKey) !== quotaFingerprint(input.quota)) {
|
|
252
282
|
recordProviderQuotaSnapshot({ provider, accountKey, quota: input.quota, sourceAgentId: input.sourceAgentId }, now);
|
|
253
283
|
}
|
|
284
|
+
if (input.quota) {
|
|
285
|
+
const quotaAdvisoryState = providerQuotaAdvisoryHandler?.({
|
|
286
|
+
provider,
|
|
287
|
+
accountKey,
|
|
288
|
+
previousQuota: existing?.quota_state ? parseJson<QuotaState>(existing.quota_state, { windows: [], source: "probe", updatedAt: 0 }) : undefined,
|
|
289
|
+
quota: input.quota,
|
|
290
|
+
sourceAgentIds,
|
|
291
|
+
previousAdvisoryState: existing?.quota_advisory_state,
|
|
292
|
+
now,
|
|
293
|
+
});
|
|
294
|
+
if (quotaAdvisoryState !== undefined) {
|
|
295
|
+
getDb().query("UPDATE provider_quotas SET quota_advisory_state = ? WHERE provider = ? AND account_key = ?").run(quotaAdvisoryState, provider, accountKey);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (!unstableProviderQuotaAccountKey(accountKey)) {
|
|
299
|
+
pruneUnstableProviderQuotaRecords(provider);
|
|
300
|
+
}
|
|
254
301
|
pruneProviderQuotaRecords(provider, undefined, now - DAY_MS);
|
|
255
302
|
|
|
256
303
|
return listProviderQuotas({ provider, accountKey, historyLimit: 24, now }).quotas[0]!;
|
|
@@ -291,19 +338,22 @@ function pruneProviderQuotaRecords(provider?: string, accountKey?: string, older
|
|
|
291
338
|
return result.changes;
|
|
292
339
|
}
|
|
293
340
|
|
|
341
|
+
function pruneUnstableProviderQuotaRecords(provider?: string): number {
|
|
342
|
+
const result = provider
|
|
343
|
+
? getDb().query("DELETE FROM provider_quotas WHERE provider = ? AND (account_key LIKE 'host:%' OR account_key LIKE 'home:%')").run(provider)
|
|
344
|
+
: getDb().query("DELETE FROM provider_quotas WHERE account_key LIKE 'host:%' OR account_key LIKE 'home:%'").run();
|
|
345
|
+
return result.changes;
|
|
346
|
+
}
|
|
347
|
+
|
|
294
348
|
export function normalizeProviderQuotaAccountKeys(now = Date.now()): number {
|
|
295
349
|
let changes = 0;
|
|
296
350
|
for (const row of getDb().query("SELECT * FROM provider_quotas").all() as ProviderQuotaRow[]) {
|
|
297
|
-
|
|
298
|
-
if (accountKey.startsWith("credential:")) {
|
|
299
|
-
const sourceAgentId = normalizeSourceAgentIds(row.source_agent_ids)[0];
|
|
300
|
-
const host = sourceAgentHost(sourceAgentId);
|
|
301
|
-
if (host) accountKey = `host:${host}`;
|
|
302
|
-
}
|
|
351
|
+
const accountKey = canonicalProviderQuotaAccountKey(row.provider, row.account_key);
|
|
303
352
|
if (accountKey !== row.account_key) {
|
|
304
353
|
changes += mergeProviderQuotaRows(row.provider, accountKey, [row.account_key], now);
|
|
305
354
|
}
|
|
306
355
|
}
|
|
356
|
+
changes += pruneUnstableProviderQuotaRecords();
|
|
307
357
|
changes += pruneProviderQuotaRecords(undefined, undefined, now - DAY_MS);
|
|
308
358
|
return changes;
|
|
309
359
|
}
|
package/src/db/schema.ts
CHANGED
|
@@ -307,6 +307,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
307
307
|
account_key TEXT NOT NULL,
|
|
308
308
|
quota_state TEXT,
|
|
309
309
|
source_agent_ids TEXT NOT NULL DEFAULT '[]',
|
|
310
|
+
quota_advisory_state TEXT NOT NULL DEFAULT '{}',
|
|
310
311
|
last_updated_at INTEGER,
|
|
311
312
|
last_attempt_at INTEGER NOT NULL,
|
|
312
313
|
last_error TEXT,
|
|
@@ -315,6 +316,18 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
315
316
|
PRIMARY KEY (provider, account_key)
|
|
316
317
|
);
|
|
317
318
|
|
|
319
|
+
CREATE TABLE IF NOT EXISTS provider_quota_leases (
|
|
320
|
+
provider TEXT NOT NULL,
|
|
321
|
+
account_key TEXT NOT NULL,
|
|
322
|
+
orchestrator_id TEXT NOT NULL,
|
|
323
|
+
lease_token TEXT NOT NULL,
|
|
324
|
+
expires_at INTEGER NOT NULL,
|
|
325
|
+
updated_at INTEGER NOT NULL,
|
|
326
|
+
created_at INTEGER NOT NULL,
|
|
327
|
+
PRIMARY KEY (provider, account_key)
|
|
328
|
+
);
|
|
329
|
+
CREATE INDEX IF NOT EXISTS idx_provider_quota_leases_expires ON provider_quota_leases(expires_at);
|
|
330
|
+
|
|
318
331
|
CREATE TABLE IF NOT EXISTS agent_continuations (
|
|
319
332
|
agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
|
|
320
333
|
objective TEXT NOT NULL,
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { getCompactionWatch } from "./compaction-watch";
|
|
|
11
11
|
import { getConfig, setConfig } from "./config-store";
|
|
12
12
|
import { startConnectorStatusPoller } from "./connectors";
|
|
13
13
|
import { installNotificationSubscriptionRule } from "./notification-subscription-rules";
|
|
14
|
+
import { installQuotaAdvisoryHandler } from "./quota-advisory";
|
|
14
15
|
import { startPlanLiveBindings } from "./services/plan-live-bindings";
|
|
15
16
|
import { resolve, sep } from "path";
|
|
16
17
|
import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|
@@ -76,6 +77,7 @@ function startServer(): void {
|
|
|
76
77
|
|
|
77
78
|
assertSafeNetworkConfig(HOST);
|
|
78
79
|
initDb(DB_PATH);
|
|
80
|
+
installQuotaAdvisoryHandler();
|
|
79
81
|
installNotificationSubscriptionRule();
|
|
80
82
|
startPlanLiveBindings();
|
|
81
83
|
getLifecycleManager().start();
|
|
@@ -5,6 +5,7 @@ export type NotificationType =
|
|
|
5
5
|
| "agent.exited"
|
|
6
6
|
| "agent.spawn_failed"
|
|
7
7
|
| "agent.context_threshold"
|
|
8
|
+
| "agent.quota_threshold"
|
|
8
9
|
| "agent.resume_budget_warning"
|
|
9
10
|
| "agent.resume_budget_exhausted"
|
|
10
11
|
| "agent.transient_land_hold"
|
|
@@ -54,6 +55,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
|
|
|
54
55
|
"agent.exited": null,
|
|
55
56
|
"agent.spawn_failed": null,
|
|
56
57
|
"agent.context_threshold": 15 * MINUTES,
|
|
58
|
+
"agent.quota_threshold": 15 * MINUTES,
|
|
57
59
|
"agent.resume_budget_warning": null,
|
|
58
60
|
"agent.resume_budget_exhausted": null,
|
|
59
61
|
"agent.transient_land_hold": null,
|
|
@@ -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,
|