agent-relay-server 0.87.0 → 0.88.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +97 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-DS4Hbn0k.js → MessageBubble-bI9fFi55.js} +3 -3
- package/public/assets/{MessageBubble-DS4Hbn0k.js.map → MessageBubble-bI9fFi55.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BYlragEi.js → PlanGraphPanel-a3Z57nIZ.js} +2 -2
- package/public/assets/{PlanGraphPanel-BYlragEi.js.map → PlanGraphPanel-a3Z57nIZ.js.map} +1 -1
- package/public/assets/{activity-CcGFS_sY.js → activity-VTlBVIox.js} +2 -2
- package/public/assets/{activity-CcGFS_sY.js.map → activity-VTlBVIox.js.map} +1 -1
- package/public/assets/{agents-BTh8SQiP.js → agents-C2TNRCn1.js} +2 -2
- package/public/assets/{agents-BTh8SQiP.js.map → agents-C2TNRCn1.js.map} +1 -1
- package/public/assets/{automation-izYGpZyQ.js → automation-CUSyRZla.js} +2 -2
- package/public/assets/{automation-izYGpZyQ.js.map → automation-CUSyRZla.js.map} +1 -1
- package/public/assets/{chat-ThyPRUPq.js → chat-BEssCn_H.js} +2 -2
- package/public/assets/{chat-ThyPRUPq.js.map → chat-BEssCn_H.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DEYxGoiw.js → formatted-body-impl-DBbmythT.js} +2 -2
- package/public/assets/{formatted-body-impl-DEYxGoiw.js.map → formatted-body-impl-DBbmythT.js.map} +1 -1
- package/public/assets/{index-Bd1NBLyd.js → index-CiBdEE9x.js} +6 -6
- package/public/assets/{index-Bd1NBLyd.js.map → index-CiBdEE9x.js.map} +1 -1
- package/public/assets/index-F-FSPv4X.css +2 -0
- package/public/assets/{insights-DAVKQ_SF.js → insights-9P_MGbMS.js} +2 -2
- package/public/assets/{insights-DAVKQ_SF.js.map → insights-9P_MGbMS.js.map} +1 -1
- package/public/assets/{maintenance-BL4R9076.js → maintenance-CA1XujU3.js} +2 -2
- package/public/assets/{maintenance-BL4R9076.js.map → maintenance-CA1XujU3.js.map} +1 -1
- package/public/assets/managed-agents-DCfSlqW7.js +4 -0
- package/public/assets/managed-agents-DCfSlqW7.js.map +1 -0
- package/public/assets/{markdown-preview-impl-DK0wNWXJ.js → markdown-preview-impl-MgZ3Nr7K.js} +2 -2
- package/public/assets/{markdown-preview-impl-DK0wNWXJ.js.map → markdown-preview-impl-MgZ3Nr7K.js.map} +1 -1
- package/public/assets/{memory-BJEkDkwl.js → memory-DatJLjrc.js} +2 -2
- package/public/assets/{memory-BJEkDkwl.js.map → memory-DatJLjrc.js.map} +1 -1
- package/public/assets/{messages-DBn9tXx2.js → messages-CQsmbtzx.js} +2 -2
- package/public/assets/{messages-DBn9tXx2.js.map → messages-CQsmbtzx.js.map} +1 -1
- package/public/assets/{orchestrators-Bc1eC16Q.js → orchestrators-CAWYWXmh.js} +2 -2
- package/public/assets/{orchestrators-Bc1eC16Q.js.map → orchestrators-CAWYWXmh.js.map} +1 -1
- package/public/assets/overview-EqnqXVUb.js +2 -0
- package/public/assets/{overview-Bw7zrSqR.js.map → overview-EqnqXVUb.js.map} +1 -1
- package/public/assets/{pairs-BSwWnLK2.js → pairs-BfKenirO.js} +2 -2
- package/public/assets/{pairs-BSwWnLK2.js.map → pairs-BfKenirO.js.map} +1 -1
- package/public/assets/{security-wTBapq4K.js → security-CJVJ9MgS.js} +2 -2
- package/public/assets/{security-wTBapq4K.js.map → security-CJVJ9MgS.js.map} +1 -1
- package/public/assets/{select-D6HFOeLW.js → select-BcmIzk-E.js} +2 -2
- package/public/assets/{select-D6HFOeLW.js.map → select-BcmIzk-E.js.map} +1 -1
- package/public/assets/{settings-CDzp7vqJ.js → settings-BWO-UyHv.js} +2 -2
- package/public/assets/{settings-CDzp7vqJ.js.map → settings-BWO-UyHv.js.map} +1 -1
- package/public/assets/{tasks-Bd7Wjy6e.js → tasks-DTdI_MJi.js} +2 -2
- package/public/assets/{tasks-Bd7Wjy6e.js.map → tasks-DTdI_MJi.js.map} +1 -1
- package/public/assets/{teams-l3GUr2XV.js → teams-C9C3KVaV.js} +2 -2
- package/public/assets/{teams-l3GUr2XV.js.map → teams-C9C3KVaV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-BQMMWctd.js → terminal-viewer-impl-BDBa94-B.js} +2 -2
- package/public/assets/{terminal-viewer-impl-BQMMWctd.js.map → terminal-viewer-impl-BDBa94-B.js.map} +1 -1
- package/public/assets/{user-avatar-DcA69UJK.js → user-avatar-BL-CJwnn.js} +2 -2
- package/public/assets/{user-avatar-DcA69UJK.js.map → user-avatar-BL-CJwnn.js.map} +1 -1
- package/public/assets/{work-queue-BYlFeJ1g.js → work-queue-Vc_2-4DG.js} +2 -2
- package/public/assets/{work-queue-BYlFeJ1g.js.map → work-queue-Vc_2-4DG.js.map} +1 -1
- package/public/index.html +2 -2
- package/src/commands-db.ts +19 -2
- package/src/db/activity.ts +92 -0
- package/src/db/provider-quotas.ts +65 -4
- package/src/mcp/index.ts +3 -0
- package/src/mcp-ntfy-tools.ts +35 -0
- package/src/ntfy.ts +222 -0
- package/src/quota-advisory.ts +3 -2
- package/src/quota-threshold-config.ts +1 -1
- package/src/routes/index.ts +5 -2
- package/src/routes/provider-quotas.ts +50 -0
- package/src/routes/spawn-policy.ts +19 -2
- package/src/security.ts +3 -0
- package/public/assets/index-BjbvhfFt.css +0 -2
- package/public/assets/managed-agents-C7n1gl7E.js +0 -2
- package/public/assets/managed-agents-C7n1gl7E.js.map +0 -1
- package/public/assets/overview-Bw7zrSqR.js +0 -2
package/src/ntfy.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { MAX_BODY_BYTES } from "./config";
|
|
3
|
+
import { getConfig } from "./config-store";
|
|
4
|
+
import { upsertAgent, ValidationError } from "./db";
|
|
5
|
+
import { registerChannelTypeSetting } from "./settings/modules";
|
|
6
|
+
import { cleanString, cleanStringArray } from "./validation";
|
|
7
|
+
import { errMessage, isRecord, type JSONSchema } from "agent-relay-sdk";
|
|
8
|
+
|
|
9
|
+
export const NTFY_CONFIG_NAMESPACE = "channel-type";
|
|
10
|
+
export const NTFY_CONFIG_KEY = "ntfy";
|
|
11
|
+
export const NTFY_CHANNEL_ID = "ntfy:default";
|
|
12
|
+
|
|
13
|
+
export const NTFY_PRIORITIES = ["min", "low", "default", "high", "urgent"] as const;
|
|
14
|
+
export type NtfyPriority = (typeof NTFY_PRIORITIES)[number];
|
|
15
|
+
|
|
16
|
+
export interface NtfyChannelConfig {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
serverUrl: string;
|
|
19
|
+
topic: string;
|
|
20
|
+
tokenEnv?: string;
|
|
21
|
+
username?: string;
|
|
22
|
+
passwordEnv?: string;
|
|
23
|
+
defaultTags: string[];
|
|
24
|
+
defaultClick?: string;
|
|
25
|
+
timeoutMs: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NtfyNotificationInput {
|
|
29
|
+
title: string;
|
|
30
|
+
body: string;
|
|
31
|
+
priority?: NtfyPriority;
|
|
32
|
+
tags?: string[];
|
|
33
|
+
click?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NtfyNotificationResult {
|
|
37
|
+
ok: true;
|
|
38
|
+
status: number;
|
|
39
|
+
channelId: string;
|
|
40
|
+
topic: string;
|
|
41
|
+
priority: NtfyPriority;
|
|
42
|
+
tags: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_NTFY_CONFIG: NtfyChannelConfig = {
|
|
46
|
+
enabled: false,
|
|
47
|
+
serverUrl: "",
|
|
48
|
+
topic: "",
|
|
49
|
+
defaultTags: [],
|
|
50
|
+
timeoutMs: 15_000,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const NTFY_CONFIG_SCHEMA: JSONSchema = {
|
|
54
|
+
type: "object",
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
required: ["enabled", "serverUrl", "topic"],
|
|
57
|
+
properties: {
|
|
58
|
+
enabled: { type: "boolean" },
|
|
59
|
+
serverUrl: { type: "string", maxLength: 500 },
|
|
60
|
+
topic: { type: "string", maxLength: 200 },
|
|
61
|
+
tokenEnv: { type: "string", maxLength: 120, pattern: "^[A-Z0-9_]+$" },
|
|
62
|
+
username: { type: "string", maxLength: 120 },
|
|
63
|
+
passwordEnv: { type: "string", maxLength: 120, pattern: "^[A-Z0-9_]+$" },
|
|
64
|
+
defaultTags: { type: "array", maxItems: 20, items: { type: "string", maxLength: 40 } },
|
|
65
|
+
defaultClick: { type: "string", maxLength: 1000 },
|
|
66
|
+
timeoutMs: { type: "integer", minimum: 1_000, maximum: 60_000 },
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
registerChannelTypeSetting({
|
|
71
|
+
type: NTFY_CONFIG_KEY,
|
|
72
|
+
schema: NTFY_CONFIG_SCHEMA,
|
|
73
|
+
defaults: DEFAULT_NTFY_CONFIG,
|
|
74
|
+
display: {
|
|
75
|
+
label: "ntfy outgoing channel",
|
|
76
|
+
description: "Configures the built-in egress-only ntfy notification channel.",
|
|
77
|
+
order: 121,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export function getNtfyConfig(): NtfyChannelConfig {
|
|
82
|
+
const stored = getConfig<Partial<NtfyChannelConfig>>(NTFY_CONFIG_NAMESPACE, NTFY_CONFIG_KEY)?.value;
|
|
83
|
+
return normalizeNtfyConfig({ ...DEFAULT_NTFY_CONFIG, ...(isRecord(stored) ? stored : {}) });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function sendNtfyNotification(input: NtfyNotificationInput): Promise<NtfyNotificationResult> {
|
|
87
|
+
const config = getNtfyConfig();
|
|
88
|
+
validateSendConfig(config);
|
|
89
|
+
const title = cleanString(input.title, "title", { required: true, max: 200 })!;
|
|
90
|
+
const body = cleanString(input.body, "body", { required: true, max: MAX_BODY_BYTES })!;
|
|
91
|
+
const priority = input.priority ?? "default";
|
|
92
|
+
const tags = [...new Set([...(config.defaultTags ?? []), ...(input.tags ?? [])].map((tag) => tag.trim()).filter(Boolean))];
|
|
93
|
+
const click = cleanString(input.click ?? config.defaultClick, "click", { max: 1000 });
|
|
94
|
+
const url = ntfyTopicUrl(config);
|
|
95
|
+
const headers = ntfyHeaders({ config, title, priority, tags, click });
|
|
96
|
+
|
|
97
|
+
ensureNtfyChannelRegistered(config);
|
|
98
|
+
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
101
|
+
let response: Response;
|
|
102
|
+
try {
|
|
103
|
+
response = await fetch(url, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers,
|
|
106
|
+
body,
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (controller.signal.aborted) throw new Error(`ntfy notify timed out after ${config.timeoutMs}ms`);
|
|
111
|
+
throw new Error(`ntfy notify failed: ${errMessage(error)}`);
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
const text = await response.text().catch(() => "");
|
|
118
|
+
throw new Error(`ntfy notify failed: HTTP ${response.status}${text ? `: ${text.slice(0, 300)}` : ""}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { ok: true, status: response.status, channelId: NTFY_CHANNEL_ID, topic: config.topic, priority, tags };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeNtfyConfig(value: unknown): NtfyChannelConfig {
|
|
125
|
+
if (!isRecord(value)) throw new ValidationError("ntfy channel config must be an object");
|
|
126
|
+
const timeoutMs = value.timeoutMs === undefined ? DEFAULT_NTFY_CONFIG.timeoutMs : value.timeoutMs;
|
|
127
|
+
if (typeof value.enabled !== "boolean") throw new ValidationError("ntfy.enabled must be a boolean");
|
|
128
|
+
if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 60_000) {
|
|
129
|
+
throw new ValidationError("ntfy.timeoutMs must be an integer between 1000 and 60000");
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
enabled: value.enabled,
|
|
133
|
+
serverUrl: cleanString(value.serverUrl, "ntfy.serverUrl", { max: 500 }) ?? "",
|
|
134
|
+
topic: cleanString(value.topic, "ntfy.topic", { max: 200 }) ?? "",
|
|
135
|
+
tokenEnv: cleanString(value.tokenEnv, "ntfy.tokenEnv", { max: 120 }),
|
|
136
|
+
username: cleanString(value.username, "ntfy.username", { max: 120 }),
|
|
137
|
+
passwordEnv: cleanString(value.passwordEnv, "ntfy.passwordEnv", { max: 120 }),
|
|
138
|
+
defaultTags: cleanStringArray(value.defaultTags, "ntfy.defaultTags", { itemMax: 40, maxItems: 20 }) ?? [],
|
|
139
|
+
defaultClick: cleanString(value.defaultClick, "ntfy.defaultClick", { max: 1000 }),
|
|
140
|
+
timeoutMs,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validateSendConfig(config: NtfyChannelConfig): void {
|
|
145
|
+
if (!config.enabled) throw new ValidationError("ntfy channel is disabled");
|
|
146
|
+
if (!config.serverUrl || !config.topic) {
|
|
147
|
+
throw new ValidationError("ntfy channel requires channel-type/ntfy serverUrl and topic");
|
|
148
|
+
}
|
|
149
|
+
if (config.topic.includes("/") || config.topic.includes("?") || config.topic.includes("#")) {
|
|
150
|
+
throw new ValidationError("ntfy.topic must be a single topic name, not a path or URL");
|
|
151
|
+
}
|
|
152
|
+
if (config.tokenEnv && (config.username || config.passwordEnv)) {
|
|
153
|
+
throw new ValidationError("ntfy auth must use tokenEnv or username/passwordEnv, not both");
|
|
154
|
+
}
|
|
155
|
+
if (config.passwordEnv && !config.username) throw new ValidationError("ntfy.username required when passwordEnv is set");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function ntfyTopicUrl(config: NtfyChannelConfig): string {
|
|
159
|
+
let base: URL;
|
|
160
|
+
try {
|
|
161
|
+
base = new URL(config.serverUrl.endsWith("/") ? config.serverUrl : `${config.serverUrl}/`);
|
|
162
|
+
} catch {
|
|
163
|
+
throw new ValidationError("ntfy.serverUrl must be a valid URL");
|
|
164
|
+
}
|
|
165
|
+
return new URL(encodeURIComponent(config.topic), base).toString();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ntfyHeaders(input: {
|
|
169
|
+
config: NtfyChannelConfig;
|
|
170
|
+
title: string;
|
|
171
|
+
priority: NtfyPriority;
|
|
172
|
+
tags: string[];
|
|
173
|
+
click?: string;
|
|
174
|
+
}): Record<string, string> {
|
|
175
|
+
const headers: Record<string, string> = {
|
|
176
|
+
Title: input.title,
|
|
177
|
+
Priority: String(priorityValue(input.priority)),
|
|
178
|
+
};
|
|
179
|
+
if (input.tags.length) headers.Tags = input.tags.join(",");
|
|
180
|
+
if (input.click) headers.Click = input.click;
|
|
181
|
+
const authorization = authHeader(input.config);
|
|
182
|
+
if (authorization) headers.Authorization = authorization;
|
|
183
|
+
return headers;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function authHeader(config: NtfyChannelConfig): string | undefined {
|
|
187
|
+
if (config.tokenEnv) {
|
|
188
|
+
const token = cleanString(process.env[config.tokenEnv], config.tokenEnv, { required: true, max: 4_000 })!;
|
|
189
|
+
return `Bearer ${token}`;
|
|
190
|
+
}
|
|
191
|
+
if (config.username && config.passwordEnv) {
|
|
192
|
+
const password = cleanString(process.env[config.passwordEnv], config.passwordEnv, { required: true, max: 4_000 })!;
|
|
193
|
+
return `Basic ${Buffer.from(`${config.username}:${password}`).toString("base64")}`;
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function priorityValue(priority: NtfyPriority): number {
|
|
199
|
+
return NTFY_PRIORITIES.indexOf(priority) + 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function ensureNtfyChannelRegistered(config: NtfyChannelConfig): void {
|
|
203
|
+
upsertAgent({
|
|
204
|
+
id: NTFY_CHANNEL_ID,
|
|
205
|
+
name: "ntfy",
|
|
206
|
+
kind: "channel",
|
|
207
|
+
tags: ["channel", "ntfy", "channel:ntfy"],
|
|
208
|
+
capabilities: ["channel", "ntfy", "notify"],
|
|
209
|
+
status: "online",
|
|
210
|
+
ready: true,
|
|
211
|
+
meta: {
|
|
212
|
+
kind: "channel",
|
|
213
|
+
provider: "ntfy",
|
|
214
|
+
accountId: "default",
|
|
215
|
+
channelType: "ntfy",
|
|
216
|
+
transport: "ntfy",
|
|
217
|
+
direction: "outbound",
|
|
218
|
+
displayName: "ntfy",
|
|
219
|
+
topicChannels: [config.topic],
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
package/src/quota-advisory.ts
CHANGED
|
@@ -29,6 +29,7 @@ type AgentRecipientRow = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
const RESET_ROUNDING_PERCENT = 99;
|
|
32
|
+
const QUOTA_THRESHOLD_EPSILON = 1e-9;
|
|
32
33
|
|
|
33
34
|
export function installQuotaAdvisoryHandler(): void {
|
|
34
35
|
setProviderQuotaAdvisoryHandler(handleProviderQuotaAdvisory);
|
|
@@ -62,14 +63,14 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
|
|
|
62
63
|
continue;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
if (remaining
|
|
66
|
+
if (remaining - config.warnRemaining > QUOTA_THRESHOLD_EPSILON) {
|
|
66
67
|
if (previous) delete nextState[window.name];
|
|
67
68
|
continue;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
const band = quotaRemainingBand(remaining, config.bandSize);
|
|
71
72
|
if (previous?.lastQuotaAdvisoryBand === band) continue;
|
|
72
|
-
const stage: QuotaAdvisoryStage = remaining
|
|
73
|
+
const stage: QuotaAdvisoryStage = remaining - config.criticalRemaining <= QUOTA_THRESHOLD_EPSILON ? "critical" : "warn";
|
|
73
74
|
notifyQuotaAdvisory({
|
|
74
75
|
provider: input.provider,
|
|
75
76
|
accountKey: input.accountKey,
|
|
@@ -4,7 +4,7 @@ import type { NotificationsConfig } from "./types";
|
|
|
4
4
|
|
|
5
5
|
export const DEFAULT_QUOTA_THRESHOLD_CONFIG: NotificationsConfig["quotaThreshold"] = {
|
|
6
6
|
enabled: true,
|
|
7
|
-
warnRemaining: 0.
|
|
7
|
+
warnRemaining: 0.15,
|
|
8
8
|
criticalRemaining: 0.1,
|
|
9
9
|
resetRemaining: 0.99,
|
|
10
10
|
bandSize: 0.05,
|
package/src/routes/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { deleteMyAvatar, deleteUserAvatarById, getMe, getUserAvatar, getUsers, h
|
|
|
6
6
|
import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
|
|
7
7
|
import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomations, patchAutomation, postAutomation, postAutomationRun } from "./automations";
|
|
8
8
|
import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
|
|
9
|
-
import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
|
|
9
|
+
import { getProviderQuotasRoute, postProviderQuotaLeaseAcquireRoute, postProviderQuotaLeaseReleaseRoute, postProviderQuotaPublisherLeaseAcquireRoute, postProviderQuotaPublisherLeaseReleaseRoute, postProviderQuotaRoute } from "./provider-quotas";
|
|
10
10
|
import { getProviderQuotaConfigRoute, getProviderQuotaConfigsRoute, putProviderQuotaConfigRoute } from "./provider-quota-config";
|
|
11
11
|
import { getTeamsConfigRoute, putTeamsConfigRoute } from "./teams-config";
|
|
12
12
|
import { deleteConfigKey, getConfigKey, getConfigKeyHistory, getConfigNamespace, putConfigKey } from "./config";
|
|
@@ -14,7 +14,7 @@ import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute,
|
|
|
14
14
|
import { deleteMemoryRoute, getMemories, getMemoryBrokerInfo, getMemoryById, getMemoryStats, patchMemory, postMemory, postMemoryInject, postMemorySearch } from "./memory";
|
|
15
15
|
import { deleteMessageById, getCursorRoute, getMessageById, getMessageDeliveryRoute, getMessageStatusRoute, getMessageThread, getMessages, getQueuedMessagesRoute, patchMessage, postClaimMessage, postMessage, postMessageDeliveryAction, postMessageDeliveryAttempt, postMessageReaction, postRenewMessageClaim, postSystemBroadcast } from "./messages";
|
|
16
16
|
import { deleteOrchestratorById, getOrchestratorById, getOrchestrators, patchOrchestratorAgents, postOrchestrator, postOrchestratorAction, postOrchestratorHeartbeat } from "./orchestrator";
|
|
17
|
-
import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
|
|
17
|
+
import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyDiagnostics, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
|
|
18
18
|
import { deleteTokenProfileRoute, getTokenById, getTokenMe, getTokenProfiles, getTokens, patchTokenProfile, postIntegrationRuntimeToken, postInteractiveRunnerRuntimeToken, postMcpRuntimeToken, postOrchestratorRunnerToken, postRuntimeTokenRenew, postToken, postTokenProfile, postTokenRevoke } from "./tokens";
|
|
19
19
|
import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceRecoveryBranches, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim, postWorkspaceRecoveryBranchDiscard } from "./workspaces";
|
|
20
20
|
import { error, type Handler } from "./_shared";
|
|
@@ -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),
|
|
@@ -259,6 +261,7 @@ const routes: Route[] = [
|
|
|
259
261
|
route("GET", "/api/spawn-policies/health", getSpawnPoliciesHealth),
|
|
260
262
|
route("GET", "/api/spawn-policy/:name/status", getSpawnPolicyStatus),
|
|
261
263
|
route("GET", "/api/spawn-policy/:name/health", getSpawnPolicyHealth),
|
|
264
|
+
route("GET", "/api/spawn-policy/:name/diagnostics", getSpawnPolicyDiagnostics),
|
|
262
265
|
route("GET", "/api/providers", getProvidersRoute),
|
|
263
266
|
route("GET", "/api/providers/config", getProviderConfigsRoute),
|
|
264
267
|
route("GET", "/api/providers/quota-config", getProviderQuotaConfigsRoute),
|
|
@@ -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);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Domain: spawn-policy.
|
|
2
|
-
import { ValidationError, getOrchestrator } from "../db";
|
|
3
|
-
import { auditEvent, authAuditMetadata, dashboardAttribution, emitCommand, error, json, parseBody, spawnRequestId, type Handler } from "./_shared";
|
|
2
|
+
import { ValidationError, getCrashReport, getManagedPolicyTimeline, getOrchestrator } from "../db";
|
|
3
|
+
import { auditEvent, authAuditMetadata, dashboardAttribution, emitCommand, error, json, parseBody, parseQueryInt, spawnRequestId, type Handler } from "./_shared";
|
|
4
4
|
import { buildManagedSpawnParams } from "../managed-policy";
|
|
5
5
|
import { cleanString } from "../validation";
|
|
6
6
|
import { createCommand } from "../commands-db";
|
|
@@ -229,3 +229,20 @@ export const getSpawnPolicyHealth: Handler = (_req, params) => {
|
|
|
229
229
|
const entry = getSpawnPolicy(params.name!);
|
|
230
230
|
return entry ? json(policyStatusPayload(entry.value)) : error("spawn policy not found", 404);
|
|
231
231
|
};
|
|
232
|
+
|
|
233
|
+
export const getSpawnPolicyDiagnostics: Handler = (req, params) => {
|
|
234
|
+
const entry = getSpawnPolicy(params.name!);
|
|
235
|
+
if (!entry) return error("spawn policy not found", 404);
|
|
236
|
+
const url = new URL(req.url);
|
|
237
|
+
const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 200, clamp: true });
|
|
238
|
+
if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 200");
|
|
239
|
+
const sinceRaw = parseQueryInt(url.searchParams.get("since"), { min: 0, max: Number.MAX_SAFE_INTEGER });
|
|
240
|
+
if (Number.isNaN(sinceRaw)) return error("since must be a non-negative integer");
|
|
241
|
+
const status = policyStatusPayload(entry.value);
|
|
242
|
+
const agentId = typeof status.state.agentId === "string" ? status.state.agentId : undefined;
|
|
243
|
+
return json({
|
|
244
|
+
...status,
|
|
245
|
+
timeline: getManagedPolicyTimeline(entry.value.name, { limit: limitRaw ?? 100, since: sinceRaw ?? undefined }),
|
|
246
|
+
crashReport: agentId ? getCrashReport(agentId) : null,
|
|
247
|
+
});
|
|
248
|
+
};
|
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.
|