nexus-agentd 0.2.5 → 0.3.0
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/README.md +359 -250
- package/dist/a2a/runtime.d.ts +7 -1
- package/dist/a2a/runtime.js +290 -17
- package/dist/acp/runtime.d.ts +5 -1
- package/dist/acp/runtime.js +88 -39
- package/dist/artifact-store.d.ts +162 -0
- package/dist/artifact-store.js +1081 -0
- package/dist/cli.js +13 -14
- package/dist/completion-contract.js +1 -1
- package/dist/config.js +45 -5
- package/dist/control-plane-validation.d.ts +54 -0
- package/dist/control-plane-validation.js +152 -0
- package/dist/control-plane.d.ts +9 -32
- package/dist/control-plane.js +52 -158
- package/dist/drivers/index.js +12 -0
- package/dist/drivers/pi.js +1 -1
- package/dist/drivers/stdio.d.ts +4 -1
- package/dist/drivers/stdio.js +135 -19
- package/dist/events.d.ts +9 -0
- package/dist/events.js +17 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +33 -6
- package/dist/process-tree.js +15 -6
- package/dist/run-store.d.ts +57 -7
- package/dist/run-store.js +188 -23
- package/dist/server/auth-policy.d.ts +27 -0
- package/dist/server/auth-policy.js +143 -0
- package/dist/server/connection.d.ts +8 -0
- package/dist/server/connection.js +67 -0
- package/dist/server/diagnostics.d.ts +40 -0
- package/dist/server/diagnostics.js +130 -0
- package/dist/server/metrics.d.ts +51 -0
- package/dist/server/metrics.js +56 -0
- package/dist/server/quota.d.ts +59 -0
- package/dist/server/quota.js +176 -0
- package/dist/server/validation.d.ts +29 -0
- package/dist/server/validation.js +221 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +682 -418
- package/dist/session/admin-runs.d.ts +73 -0
- package/dist/session/admin-runs.js +297 -0
- package/dist/session/admission.d.ts +32 -0
- package/dist/session/admission.js +62 -0
- package/dist/session/readiness.d.ts +33 -0
- package/dist/session/readiness.js +174 -0
- package/dist/session-contract.d.ts +1 -0
- package/dist/session.d.ts +64 -7
- package/dist/session.js +321 -159
- package/dist/sse-writer.d.ts +7 -0
- package/dist/sse-writer.js +60 -0
- package/dist/types.d.ts +44 -2
- package/dist/types.js +1 -0
- package/dist/webui/app/sources.js +18 -10
- package/dist/webui/index.js +11 -11
- package/dist/webui/markup.js +69 -66
- package/dist/webui/styles.js +791 -616
- package/dist/workspace.d.ts +30 -0
- package/dist/workspace.js +67 -2
- package/examples/client.mjs +102 -0
- package/nexus-agentd.example.json +21 -2
- package/package.json +6 -3
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createSseWriter } from '../sse-writer.js';
|
|
2
|
+
/**
|
|
3
|
+
* Own the lifecycle of one session event connection. Authentication and quota
|
|
4
|
+
* admission happen in the data route; this module is responsible only for
|
|
5
|
+
* replay, authorization revocation, heartbeats, and releasing the slot.
|
|
6
|
+
*/
|
|
7
|
+
export function streamSessionEvents(request, response, sessions, sessionId, after, release, watchAuthorization) {
|
|
8
|
+
response.writeHead(200, {
|
|
9
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
10
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
11
|
+
Connection: 'keep-alive',
|
|
12
|
+
'X-Accel-Buffering': 'no'
|
|
13
|
+
});
|
|
14
|
+
response.flushHeaders?.();
|
|
15
|
+
let unsubscribe = () => { };
|
|
16
|
+
let unwatch = () => { };
|
|
17
|
+
let heartbeat;
|
|
18
|
+
let closed = false;
|
|
19
|
+
const close = () => {
|
|
20
|
+
if (closed)
|
|
21
|
+
return;
|
|
22
|
+
closed = true;
|
|
23
|
+
clearInterval(heartbeat);
|
|
24
|
+
unsubscribe();
|
|
25
|
+
unwatch();
|
|
26
|
+
writer.stop();
|
|
27
|
+
request.off('close', close);
|
|
28
|
+
response.off('close', close);
|
|
29
|
+
request.socket.off('error', close);
|
|
30
|
+
response.destroy();
|
|
31
|
+
release();
|
|
32
|
+
};
|
|
33
|
+
const writer = createSseWriter(response, close);
|
|
34
|
+
const writeEvent = (event) => writer.write(`id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
35
|
+
request.once('close', close);
|
|
36
|
+
response.once('close', close);
|
|
37
|
+
request.socket.once('error', close);
|
|
38
|
+
try {
|
|
39
|
+
unwatch = watchAuthorization(close);
|
|
40
|
+
if (closed)
|
|
41
|
+
return;
|
|
42
|
+
const replay = sessions.eventReplay(sessionId, after);
|
|
43
|
+
if (replay.reset) {
|
|
44
|
+
// Keep this control frame bounded even when a snapshot has large
|
|
45
|
+
// output. Reconnect with the advertised cursor and snapshot URL.
|
|
46
|
+
writer.write(`id: ${replay.latestId}\nevent: reset\ndata: ${JSON.stringify({
|
|
47
|
+
...replay.reset,
|
|
48
|
+
earliestId: replay.earliestId,
|
|
49
|
+
latestId: replay.latestId,
|
|
50
|
+
snapshotUrl: `/v1/sessions/${encodeURIComponent(sessionId)}`
|
|
51
|
+
})}\n\n`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
for (const event of replay.events)
|
|
55
|
+
if (!writeEvent(event))
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
if (closed)
|
|
59
|
+
return;
|
|
60
|
+
unsubscribe = sessions.subscribe(sessionId, writeEvent);
|
|
61
|
+
heartbeat = setInterval(() => writer.write(': heartbeat\n\n'), 15_000);
|
|
62
|
+
heartbeat.unref?.();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AgentdAgentConfig, AgentdSessionView } from '../types.js';
|
|
2
|
+
type SessionCreator = {
|
|
3
|
+
create(agentId: string, workspace: string | undefined, ownerKeyId: string): Promise<AgentdSessionView>;
|
|
4
|
+
close(id: string): Promise<AgentdSessionView>;
|
|
5
|
+
};
|
|
6
|
+
export interface DiagnosticStage {
|
|
7
|
+
name: 'validate' | 'handshake' | 'close';
|
|
8
|
+
status: 'passed' | 'failed' | 'skipped';
|
|
9
|
+
durationMs: number;
|
|
10
|
+
message?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface DiagnosticResult {
|
|
13
|
+
ok: boolean;
|
|
14
|
+
status: 'passed' | 'failed' | 'busy';
|
|
15
|
+
durationMs: number;
|
|
16
|
+
stages: DiagnosticStage[];
|
|
17
|
+
message?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare class DiagnosticConcurrencyError extends Error {
|
|
20
|
+
readonly status = 429;
|
|
21
|
+
readonly retryAfterMs = 1000;
|
|
22
|
+
constructor();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Run the real SessionManager startup path as a diagnostic. SessionManager's
|
|
26
|
+
* `create` performs ACP initialize + session/new (or A2A card/transport
|
|
27
|
+
* setup), and `close` releases the process and the session slot. No prompt
|
|
28
|
+
* is sent here by design.
|
|
29
|
+
*/
|
|
30
|
+
export declare class DiagnosticRunner {
|
|
31
|
+
private readonly maxConcurrent;
|
|
32
|
+
private active;
|
|
33
|
+
constructor(maxConcurrent?: number);
|
|
34
|
+
run(sessions: SessionCreator, agentId: string, workspace: string | undefined, agentConfig?: AgentdAgentConfig): Promise<DiagnosticResult>;
|
|
35
|
+
count(): number;
|
|
36
|
+
private result;
|
|
37
|
+
}
|
|
38
|
+
/** Keep command lines, absolute paths and credential-shaped values out of UI. */
|
|
39
|
+
export declare function safeDiagnosticMessage(error: unknown): string;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export class DiagnosticConcurrencyError extends Error {
|
|
2
|
+
status = 429;
|
|
3
|
+
retryAfterMs = 1_000;
|
|
4
|
+
constructor() {
|
|
5
|
+
super('ACP diagnostic capacity has been reached');
|
|
6
|
+
this.name = 'DiagnosticConcurrencyError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Run the real SessionManager startup path as a diagnostic. SessionManager's
|
|
11
|
+
* `create` performs ACP initialize + session/new (or A2A card/transport
|
|
12
|
+
* setup), and `close` releases the process and the session slot. No prompt
|
|
13
|
+
* is sent here by design.
|
|
14
|
+
*/
|
|
15
|
+
export class DiagnosticRunner {
|
|
16
|
+
maxConcurrent;
|
|
17
|
+
active = 0;
|
|
18
|
+
constructor(maxConcurrent = 2) {
|
|
19
|
+
this.maxConcurrent = maxConcurrent;
|
|
20
|
+
}
|
|
21
|
+
async run(sessions, agentId, workspace, agentConfig) {
|
|
22
|
+
if (this.active >= this.maxConcurrent)
|
|
23
|
+
throw new DiagnosticConcurrencyError();
|
|
24
|
+
this.active++;
|
|
25
|
+
const startedAt = Date.now();
|
|
26
|
+
const stages = [];
|
|
27
|
+
let session;
|
|
28
|
+
try {
|
|
29
|
+
const validationStartedAt = Date.now();
|
|
30
|
+
if (!agentConfig) {
|
|
31
|
+
stages.push({
|
|
32
|
+
name: 'validate',
|
|
33
|
+
status: 'failed',
|
|
34
|
+
durationMs: elapsed(validationStartedAt),
|
|
35
|
+
message: 'Configured agent not found'
|
|
36
|
+
});
|
|
37
|
+
return this.result('failed', startedAt, stages, 'Configured agent not found');
|
|
38
|
+
}
|
|
39
|
+
if (agentConfig.enabled === false) {
|
|
40
|
+
stages.push({
|
|
41
|
+
name: 'validate',
|
|
42
|
+
status: 'failed',
|
|
43
|
+
durationMs: elapsed(validationStartedAt),
|
|
44
|
+
message: 'Agent is disabled'
|
|
45
|
+
});
|
|
46
|
+
return this.result('failed', startedAt, stages, 'Agent is disabled');
|
|
47
|
+
}
|
|
48
|
+
stages.push({
|
|
49
|
+
name: 'validate',
|
|
50
|
+
status: 'passed',
|
|
51
|
+
durationMs: elapsed(validationStartedAt),
|
|
52
|
+
message: 'Agent configuration accepted'
|
|
53
|
+
});
|
|
54
|
+
const startupStartedAt = Date.now();
|
|
55
|
+
try {
|
|
56
|
+
session = await sessions.create(agentId, workspace, 'console:diagnostic');
|
|
57
|
+
stages.push({
|
|
58
|
+
name: 'handshake',
|
|
59
|
+
status: 'passed',
|
|
60
|
+
durationMs: elapsed(startupStartedAt),
|
|
61
|
+
message: agentConfig.protocol === 'a2a'
|
|
62
|
+
? 'A2A transport and session creation completed'
|
|
63
|
+
: 'Process startup, ACP initialize and session/new completed'
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
stages.push({
|
|
68
|
+
name: 'handshake',
|
|
69
|
+
status: 'failed',
|
|
70
|
+
durationMs: elapsed(startupStartedAt),
|
|
71
|
+
message: safeDiagnosticMessage(error)
|
|
72
|
+
});
|
|
73
|
+
return this.result('failed', startedAt, stages, safeDiagnosticMessage(error));
|
|
74
|
+
}
|
|
75
|
+
const closeStartedAt = Date.now();
|
|
76
|
+
try {
|
|
77
|
+
await sessions.close(session.id);
|
|
78
|
+
stages.push({
|
|
79
|
+
name: 'close',
|
|
80
|
+
status: 'passed',
|
|
81
|
+
durationMs: elapsed(closeStartedAt),
|
|
82
|
+
message: 'Diagnostic session closed'
|
|
83
|
+
});
|
|
84
|
+
return this.result('passed', startedAt, stages, 'Agent connection diagnostic completed');
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
stages.push({
|
|
88
|
+
name: 'close',
|
|
89
|
+
status: 'failed',
|
|
90
|
+
durationMs: elapsed(closeStartedAt),
|
|
91
|
+
message: safeDiagnosticMessage(error)
|
|
92
|
+
});
|
|
93
|
+
return this.result('failed', startedAt, stages, safeDiagnosticMessage(error));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
// If close itself failed, the SessionManager still owns the
|
|
98
|
+
// session. Do not issue a second unbounded close here; its normal
|
|
99
|
+
// shutdown/cleanup path remains authoritative.
|
|
100
|
+
this.active = Math.max(0, this.active - 1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
count() {
|
|
104
|
+
return this.active;
|
|
105
|
+
}
|
|
106
|
+
result(status, startedAt, stages, message) {
|
|
107
|
+
return {
|
|
108
|
+
ok: status === 'passed',
|
|
109
|
+
status,
|
|
110
|
+
durationMs: elapsed(startedAt),
|
|
111
|
+
stages,
|
|
112
|
+
...(message ? { message } : {})
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function elapsed(startedAt) {
|
|
117
|
+
return Math.max(0, Date.now() - startedAt);
|
|
118
|
+
}
|
|
119
|
+
/** Keep command lines, absolute paths and credential-shaped values out of UI. */
|
|
120
|
+
export function safeDiagnosticMessage(error) {
|
|
121
|
+
let value = error instanceof Error ? error.message : String(error);
|
|
122
|
+
value = value
|
|
123
|
+
.replace(/Bearer\s+[^\s]+/gi, 'Bearer [redacted]')
|
|
124
|
+
.replace(/(token|secret|password|authorization)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
|
|
125
|
+
.replace(/(?:[A-Za-z]:[\\/]|\\\\)[^\s"'`]+/g, '[path]')
|
|
126
|
+
.replace(/(^|[\s(])\/(?!\/)[^\s"'`]+/g, '$1[path]')
|
|
127
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
128
|
+
.trim();
|
|
129
|
+
return value.length > 500 ? `${value.slice(0, 500)}…` : value;
|
|
130
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { QuotaManager } from './quota.js';
|
|
2
|
+
import type { RunStore } from '../run-store.js';
|
|
3
|
+
export interface GatewayMetricsSnapshot {
|
|
4
|
+
rssBytes: number;
|
|
5
|
+
sessions: number;
|
|
6
|
+
currentSse: number;
|
|
7
|
+
activeSse: number;
|
|
8
|
+
requestCount: number;
|
|
9
|
+
failures: number;
|
|
10
|
+
quotaRejections: number;
|
|
11
|
+
runs: {
|
|
12
|
+
active: number;
|
|
13
|
+
completed: number;
|
|
14
|
+
failed: number;
|
|
15
|
+
total: number;
|
|
16
|
+
};
|
|
17
|
+
recentRuns: unknown[];
|
|
18
|
+
storage?: {
|
|
19
|
+
availableBytes: number;
|
|
20
|
+
totalBytes: number;
|
|
21
|
+
};
|
|
22
|
+
storageMetrics?: ReturnType<RunStore['metrics']>;
|
|
23
|
+
quotas?: ReturnType<QuotaManager['snapshot']>;
|
|
24
|
+
}
|
|
25
|
+
/** Small process-local counters used by the admin metrics endpoint. */
|
|
26
|
+
export declare class GatewayMetrics {
|
|
27
|
+
private requests;
|
|
28
|
+
private failures;
|
|
29
|
+
private quotaRejections;
|
|
30
|
+
requestStarted(): void;
|
|
31
|
+
responseFinished(status: number, quotaRejected?: boolean): void;
|
|
32
|
+
snapshotCounters(): {
|
|
33
|
+
requestCount: number;
|
|
34
|
+
failures: number;
|
|
35
|
+
quotaRejections: number;
|
|
36
|
+
};
|
|
37
|
+
snapshot(input: {
|
|
38
|
+
sessions: number;
|
|
39
|
+
currentSse: number;
|
|
40
|
+
runs: {
|
|
41
|
+
active: number;
|
|
42
|
+
completed: number;
|
|
43
|
+
failed: number;
|
|
44
|
+
total: number;
|
|
45
|
+
};
|
|
46
|
+
recentRuns: unknown[];
|
|
47
|
+
storagePath?: string;
|
|
48
|
+
storageMetrics?: ReturnType<RunStore['metrics']>;
|
|
49
|
+
quotas?: QuotaManager;
|
|
50
|
+
}): Promise<GatewayMetricsSnapshot>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { statfs } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** Small process-local counters used by the admin metrics endpoint. */
|
|
4
|
+
export class GatewayMetrics {
|
|
5
|
+
requests = 0;
|
|
6
|
+
failures = 0;
|
|
7
|
+
quotaRejections = 0;
|
|
8
|
+
requestStarted() {
|
|
9
|
+
this.requests++;
|
|
10
|
+
}
|
|
11
|
+
responseFinished(status, quotaRejected = false) {
|
|
12
|
+
if (status >= 400)
|
|
13
|
+
this.failures++;
|
|
14
|
+
if (quotaRejected)
|
|
15
|
+
this.quotaRejections++;
|
|
16
|
+
}
|
|
17
|
+
snapshotCounters() {
|
|
18
|
+
return {
|
|
19
|
+
requestCount: this.requests,
|
|
20
|
+
failures: this.failures,
|
|
21
|
+
quotaRejections: this.quotaRejections
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
async snapshot(input) {
|
|
25
|
+
const memory = process.memoryUsage();
|
|
26
|
+
const counters = this.snapshotCounters();
|
|
27
|
+
const storage = input.storagePath
|
|
28
|
+
? await measureStorage(input.storagePath)
|
|
29
|
+
: undefined;
|
|
30
|
+
return {
|
|
31
|
+
rssBytes: memory.rss,
|
|
32
|
+
sessions: input.sessions,
|
|
33
|
+
currentSse: input.currentSse,
|
|
34
|
+
activeSse: input.currentSse,
|
|
35
|
+
...counters,
|
|
36
|
+
runs: input.runs,
|
|
37
|
+
recentRuns: input.recentRuns.slice(0, 5),
|
|
38
|
+
...(storage ? { storage } : {}),
|
|
39
|
+
...(input.storageMetrics ? { storageMetrics: input.storageMetrics } : {}),
|
|
40
|
+
...(input.quotas ? { quotas: input.quotas.snapshot() } : {})
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function measureStorage(target) {
|
|
45
|
+
try {
|
|
46
|
+
const value = await statfs(path.resolve(target));
|
|
47
|
+
const blockSize = Number(value.bsize);
|
|
48
|
+
return {
|
|
49
|
+
availableBytes: Math.max(0, Number(value.bavail) * blockSize),
|
|
50
|
+
totalBytes: Math.max(0, Number(value.blocks) * blockSize)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local per API-key resource accounting.
|
|
3
|
+
*
|
|
4
|
+
* The gateway still owns the global Session/SSE limits. This class only
|
|
5
|
+
* accounts for work admitted through the HTTP data plane, so administrator
|
|
6
|
+
* operations can keep working while a client key is at its budget.
|
|
7
|
+
*/
|
|
8
|
+
export type QuotaKind = 'sessions' | 'runningRuns' | 'sse' | 'upload';
|
|
9
|
+
export interface QuotaLimits {
|
|
10
|
+
maxSessionsPerKey: number;
|
|
11
|
+
maxRunningRunsPerKey: number;
|
|
12
|
+
maxSsePerKey: number;
|
|
13
|
+
maxUploadBytesPerKey: number;
|
|
14
|
+
}
|
|
15
|
+
export interface QuotaUsage {
|
|
16
|
+
sessions: number;
|
|
17
|
+
runningRuns: number;
|
|
18
|
+
sse: number;
|
|
19
|
+
uploadBytes: number;
|
|
20
|
+
}
|
|
21
|
+
export interface QuotaSnapshot {
|
|
22
|
+
limits: QuotaLimits;
|
|
23
|
+
keys: number;
|
|
24
|
+
usage: Record<string, QuotaUsage>;
|
|
25
|
+
}
|
|
26
|
+
export declare class QuotaExceededError extends Error {
|
|
27
|
+
readonly quota: QuotaKind;
|
|
28
|
+
readonly limit: number;
|
|
29
|
+
readonly current: number;
|
|
30
|
+
readonly status = 429;
|
|
31
|
+
readonly retryAfterMs: number;
|
|
32
|
+
constructor(quota: QuotaKind, limit: number, current: number, retryAfterMs?: number);
|
|
33
|
+
}
|
|
34
|
+
type Reservation = () => void;
|
|
35
|
+
/**
|
|
36
|
+
* Read the canonical `config.quotas` section while retaining bounded defaults
|
|
37
|
+
* for embedders that construct an in-memory config without loading the file
|
|
38
|
+
* parser first.
|
|
39
|
+
*/
|
|
40
|
+
export declare function quotaLimitsFromConfig(config: unknown): QuotaLimits;
|
|
41
|
+
export declare class QuotaManager {
|
|
42
|
+
private readonly buckets;
|
|
43
|
+
private readonly runOwners;
|
|
44
|
+
readonly limits: QuotaLimits;
|
|
45
|
+
constructor(config: unknown);
|
|
46
|
+
reserveSession(keyId: string): Reservation;
|
|
47
|
+
reserveRunningRun(keyId: string, sessionId?: string): Reservation;
|
|
48
|
+
/** Mark a run terminal and release its running slot, if one was tracked. */
|
|
49
|
+
finishRun(sessionId: string, keyId?: string): void;
|
|
50
|
+
reserveSse(keyId: string): Reservation;
|
|
51
|
+
reserveUpload(keyId: string, bytes: number): Reservation;
|
|
52
|
+
usageFor(keyId: string): QuotaUsage;
|
|
53
|
+
snapshot(): QuotaSnapshot;
|
|
54
|
+
private reserveCount;
|
|
55
|
+
private releaseCount;
|
|
56
|
+
private bucket;
|
|
57
|
+
private compact;
|
|
58
|
+
}
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local per API-key resource accounting.
|
|
3
|
+
*
|
|
4
|
+
* The gateway still owns the global Session/SSE limits. This class only
|
|
5
|
+
* accounts for work admitted through the HTTP data plane, so administrator
|
|
6
|
+
* operations can keep working while a client key is at its budget.
|
|
7
|
+
*/
|
|
8
|
+
export class QuotaExceededError extends Error {
|
|
9
|
+
quota;
|
|
10
|
+
limit;
|
|
11
|
+
current;
|
|
12
|
+
status = 429;
|
|
13
|
+
retryAfterMs;
|
|
14
|
+
constructor(quota, limit, current, retryAfterMs = 1_000) {
|
|
15
|
+
super(`Quota exceeded: ${quota}`);
|
|
16
|
+
this.quota = quota;
|
|
17
|
+
this.limit = limit;
|
|
18
|
+
this.current = current;
|
|
19
|
+
this.name = 'QuotaExceededError';
|
|
20
|
+
this.retryAfterMs = Math.max(1, Math.floor(retryAfterMs));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const ZERO_BUCKET = {
|
|
24
|
+
sessions: 0,
|
|
25
|
+
runningRuns: 0,
|
|
26
|
+
sse: 0,
|
|
27
|
+
uploadBytes: 0
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Read the canonical `config.quotas` section while retaining bounded defaults
|
|
31
|
+
* for embedders that construct an in-memory config without loading the file
|
|
32
|
+
* parser first.
|
|
33
|
+
*/
|
|
34
|
+
export function quotaLimitsFromConfig(config) {
|
|
35
|
+
const source = asRecord(config) || {};
|
|
36
|
+
const nested = asRecord(source.quotas) || {};
|
|
37
|
+
const pick = (name, fallback) => {
|
|
38
|
+
const value = nested[name];
|
|
39
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
40
|
+
? Math.floor(value)
|
|
41
|
+
: fallback;
|
|
42
|
+
};
|
|
43
|
+
const globalSessions = positiveNumber(source.maxSessions, 64);
|
|
44
|
+
const globalSse = positiveNumber(source.maxSseConnections, 128);
|
|
45
|
+
const attachmentBytes = positiveNumber(source.maxAttachmentBytes, 32 * 1024 * 1024);
|
|
46
|
+
return {
|
|
47
|
+
maxSessionsPerKey: pick('maxSessionsPerKey', Math.max(1, Math.min(16, globalSessions))),
|
|
48
|
+
maxRunningRunsPerKey: pick('maxRunningRunsPerKey', Math.max(1, Math.min(4, globalSessions))),
|
|
49
|
+
maxSsePerKey: pick('maxSsePerKey', Math.max(1, Math.min(8, globalSse))),
|
|
50
|
+
maxUploadBytesPerKey: pick('maxUploadBytesPerKey', attachmentBytes)
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export class QuotaManager {
|
|
54
|
+
buckets = new Map();
|
|
55
|
+
runOwners = new Map();
|
|
56
|
+
limits;
|
|
57
|
+
constructor(config) {
|
|
58
|
+
this.limits = quotaLimitsFromConfig(config);
|
|
59
|
+
}
|
|
60
|
+
reserveSession(keyId) {
|
|
61
|
+
return this.reserveCount(keyId, 'sessions', this.limits.maxSessionsPerKey, 1_000);
|
|
62
|
+
}
|
|
63
|
+
reserveRunningRun(keyId, sessionId) {
|
|
64
|
+
const release = this.reserveCount(keyId, 'runningRuns', this.limits.maxRunningRunsPerKey, 1_000);
|
|
65
|
+
if (sessionId)
|
|
66
|
+
this.runOwners.set(sessionId, keyId);
|
|
67
|
+
let released = false;
|
|
68
|
+
return () => {
|
|
69
|
+
if (released)
|
|
70
|
+
return;
|
|
71
|
+
released = true;
|
|
72
|
+
if (sessionId && this.runOwners.get(sessionId) === keyId) {
|
|
73
|
+
this.runOwners.delete(sessionId);
|
|
74
|
+
}
|
|
75
|
+
release();
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Mark a run terminal and release its running slot, if one was tracked. */
|
|
79
|
+
finishRun(sessionId, keyId) {
|
|
80
|
+
const owner = this.runOwners.get(sessionId);
|
|
81
|
+
if (!owner || (keyId && owner !== keyId))
|
|
82
|
+
return;
|
|
83
|
+
this.runOwners.delete(sessionId);
|
|
84
|
+
this.releaseCount(owner, 'runningRuns', 1);
|
|
85
|
+
}
|
|
86
|
+
reserveSse(keyId) {
|
|
87
|
+
return this.reserveCount(keyId, 'sse', this.limits.maxSsePerKey, 1_000);
|
|
88
|
+
}
|
|
89
|
+
reserveUpload(keyId, bytes) {
|
|
90
|
+
const amount = Math.max(0, Math.floor(bytes));
|
|
91
|
+
if (!amount)
|
|
92
|
+
return () => { };
|
|
93
|
+
return this.reserveCount(keyId, 'upload', this.limits.maxUploadBytesPerKey, 60_000, amount);
|
|
94
|
+
}
|
|
95
|
+
usageFor(keyId) {
|
|
96
|
+
const bucket = this.buckets.get(keyId);
|
|
97
|
+
return {
|
|
98
|
+
sessions: bucket?.sessions || 0,
|
|
99
|
+
runningRuns: bucket?.runningRuns || 0,
|
|
100
|
+
sse: bucket?.sse || 0,
|
|
101
|
+
uploadBytes: bucket?.uploadBytes || 0
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
snapshot() {
|
|
105
|
+
const usage = {};
|
|
106
|
+
for (const [keyId, bucket] of this.buckets) {
|
|
107
|
+
usage[keyId] = {
|
|
108
|
+
sessions: bucket.sessions,
|
|
109
|
+
runningRuns: bucket.runningRuns,
|
|
110
|
+
sse: bucket.sse,
|
|
111
|
+
uploadBytes: bucket.uploadBytes
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
limits: { ...this.limits },
|
|
116
|
+
keys: this.buckets.size,
|
|
117
|
+
usage
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
reserveCount(keyId, quota, limit, retryAfterMs, amount = 1) {
|
|
121
|
+
const bucket = this.bucket(keyId);
|
|
122
|
+
const field = quota === 'upload' ? 'uploadBytes' : quota;
|
|
123
|
+
const current = bucket[field];
|
|
124
|
+
if (current + amount > limit) {
|
|
125
|
+
this.compact();
|
|
126
|
+
throw new QuotaExceededError(quota, limit, current, retryAfterMs);
|
|
127
|
+
}
|
|
128
|
+
bucket[field] = current + amount;
|
|
129
|
+
let released = false;
|
|
130
|
+
return () => {
|
|
131
|
+
if (released)
|
|
132
|
+
return;
|
|
133
|
+
released = true;
|
|
134
|
+
this.releaseCount(keyId, quota, amount);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
releaseCount(keyId, quota, amount) {
|
|
138
|
+
const bucket = this.buckets.get(keyId);
|
|
139
|
+
if (!bucket)
|
|
140
|
+
return;
|
|
141
|
+
const field = quota === 'upload' ? 'uploadBytes' : quota;
|
|
142
|
+
bucket[field] = Math.max(0, bucket[field] - amount);
|
|
143
|
+
if (!bucket.sessions && !bucket.runningRuns && !bucket.sse && !bucket.uploadBytes) {
|
|
144
|
+
this.buckets.delete(keyId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
bucket(keyId) {
|
|
148
|
+
let bucket = this.buckets.get(keyId);
|
|
149
|
+
if (!bucket) {
|
|
150
|
+
bucket = { ...ZERO_BUCKET };
|
|
151
|
+
this.buckets.set(keyId, bucket);
|
|
152
|
+
}
|
|
153
|
+
return bucket;
|
|
154
|
+
}
|
|
155
|
+
compact() {
|
|
156
|
+
if (this.buckets.size <= 4096)
|
|
157
|
+
return;
|
|
158
|
+
for (const [keyId, bucket] of this.buckets) {
|
|
159
|
+
if (!bucket.sessions && !bucket.runningRuns && !bucket.sse && !bucket.uploadBytes) {
|
|
160
|
+
this.buckets.delete(keyId);
|
|
161
|
+
}
|
|
162
|
+
if (this.buckets.size <= 4096)
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function asRecord(value) {
|
|
168
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
169
|
+
? value
|
|
170
|
+
: undefined;
|
|
171
|
+
}
|
|
172
|
+
function positiveNumber(value, fallback) {
|
|
173
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
174
|
+
? Math.floor(value)
|
|
175
|
+
: fallback;
|
|
176
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import type { AgentdPendingResponse, AgentdSessionState } from '../types.js';
|
|
3
|
+
/** HTTP input errors are kept separate from route and domain operations. */
|
|
4
|
+
export declare class RequestError extends Error {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
constructor(status: number, message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare function assertJsonContentType(request: IncomingMessage): void;
|
|
9
|
+
export declare function assertEmptyJsonBody(request: IncomingMessage, maxBytes: number): Promise<void>;
|
|
10
|
+
export declare function readJsonBody(request: IncomingMessage, maxBytes: number): Promise<Record<string, unknown>>;
|
|
11
|
+
export declare function readBytesBody(request: IncomingMessage, maxBytes: number): Promise<Buffer<ArrayBuffer>>;
|
|
12
|
+
export declare function assertOnlyKeys(body: Record<string, unknown>, allowed: string[]): void;
|
|
13
|
+
export declare function requiredString(value: unknown, name: string): string;
|
|
14
|
+
export declare function requiredRawString(value: unknown, name: string): string;
|
|
15
|
+
export declare function requiredMessage(value: unknown): string;
|
|
16
|
+
export declare function requiredInteger(value: unknown, name: string): number;
|
|
17
|
+
export declare function optionalAttachmentIds(value: unknown): string[];
|
|
18
|
+
export declare function normalizePublishPaths(body: Record<string, unknown>): string[];
|
|
19
|
+
export declare function normalizedMediaType(value: string | string[] | undefined): string | undefined;
|
|
20
|
+
export declare function decodeHeader(value: string | string[] | undefined): string;
|
|
21
|
+
export declare function cleanQuery(value: string | null): string | undefined;
|
|
22
|
+
export declare function boundedLimit(value: string | null): number | undefined;
|
|
23
|
+
export declare function optionalRunState(value: string | null): AgentdSessionState | undefined;
|
|
24
|
+
export declare function optionalRawString(value: unknown, name: string): string | undefined;
|
|
25
|
+
export declare function optionalString(value: unknown): string | undefined;
|
|
26
|
+
export declare function optionalPendingAction(value: unknown): AgentdPendingResponse['action'] | undefined;
|
|
27
|
+
export declare function optionalBoolean(value: unknown, name: string): boolean | undefined;
|
|
28
|
+
export declare function optionalNumber(value: unknown, name: string): number | undefined;
|
|
29
|
+
export declare function requiredStringArray(value: unknown, name: string, allowEmpty?: boolean): string[];
|