nexus-agentd 0.2.3 → 0.2.6
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/LICENSE +22 -22
- package/README.md +223 -202
- package/dist/a2a/runtime.d.ts +3 -0
- package/dist/a2a/runtime.js +119 -12
- package/dist/acp/runtime.d.ts +5 -1
- package/dist/acp/runtime.js +180 -36
- package/dist/auth.js +29 -8
- package/dist/completion-contract.js +1 -1
- package/dist/control-plane.js +11 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +23 -5
- package/dist/run-store.d.ts +5 -0
- package/dist/run-store.js +10 -1
- package/dist/server.js +58 -8
- package/dist/session-contract.d.ts +1 -0
- package/dist/session.d.ts +14 -2
- package/dist/session.js +153 -74
- package/dist/types.d.ts +14 -0
- package/dist/webui/app/sources.d.ts +1 -0
- package/dist/webui/app/sources.js +17 -0
- package/dist/webui/icons.d.ts +17 -0
- package/dist/webui/icons.js +23 -6
- package/dist/webui/index.d.ts +1 -0
- package/dist/webui/index.js +32 -12
- package/dist/webui/markup.js +67 -65
- package/dist/webui/styles.js +746 -256
- package/dist/workspace.d.ts +30 -0
- package/dist/workspace.js +67 -2
- package/nexus-agentd.example.json +40 -40
- package/package.json +54 -52
- package/dist/webui/app.d.ts +0 -1
- package/dist/webui/app.js +0 -914
package/dist/control-plane.js
CHANGED
|
@@ -188,7 +188,17 @@ export class AgentdControlPlane {
|
|
|
188
188
|
if (!workspaceRoots.length) {
|
|
189
189
|
throw new ControlPlaneError(400, 'workspaceRoots must contain at least one path');
|
|
190
190
|
}
|
|
191
|
-
await WorkspacePolicy.create(workspaceRoots);
|
|
191
|
+
const policy = await WorkspacePolicy.create(workspaceRoots);
|
|
192
|
+
for (const [id, agent] of Object.entries(this.config.agents)) {
|
|
193
|
+
if (agent.protocol === 'a2a' || !agent.workspace)
|
|
194
|
+
continue;
|
|
195
|
+
try {
|
|
196
|
+
await policy.resolve(agent.workspace);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
throw new ControlPlaneError(409, `Workspace change would exclude agent: ${id}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
192
202
|
const raw = await this.readRawConfig();
|
|
193
203
|
raw.workspaceRoots = workspaceRoots;
|
|
194
204
|
await this.persist(raw);
|
package/dist/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export declare function startAgentd(configPath: string): Promise<{
|
|
|
8
8
|
sessions: SessionManager;
|
|
9
9
|
runStore: RunStore;
|
|
10
10
|
controlPlane: AgentdControlPlane;
|
|
11
|
-
close()
|
|
11
|
+
close: () => Promise<void>;
|
|
12
12
|
}>;
|
|
13
13
|
export declare function closeServer(server: Server, graceMs?: number): Promise<void>;
|
|
14
14
|
export * from './config.js';
|
package/dist/index.js
CHANGED
|
@@ -16,17 +16,35 @@ export async function startAgentd(configPath) {
|
|
|
16
16
|
sessions.startCleanup();
|
|
17
17
|
const controlPlane = new AgentdControlPlane(absoluteConfigPath, config, sessions);
|
|
18
18
|
const server = createAgentdServer(config, sessions, controlPlane);
|
|
19
|
-
|
|
19
|
+
let closing;
|
|
20
|
+
const close = () => closing ??= (async () => {
|
|
21
|
+
const results = await Promise.allSettled([closeServer(server), sessions.shutdown()]);
|
|
22
|
+
await runStore.flush();
|
|
23
|
+
const failed = results.find((result) => result.status === 'rejected');
|
|
24
|
+
if (failed)
|
|
25
|
+
throw failed.reason;
|
|
26
|
+
})();
|
|
27
|
+
try {
|
|
28
|
+
await listen(server, config.listen.port, config.listen.host);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
await sessions.shutdown();
|
|
32
|
+
await runStore.flush();
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
server.on('error', (error) => {
|
|
36
|
+
console.error(JSON.stringify({ level: 'error', event: 'server_failed', message: error.message }));
|
|
37
|
+
void close().catch((closeError) => {
|
|
38
|
+
console.error(JSON.stringify({ level: 'error', event: 'server_shutdown_failed', message: String(closeError) }));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
20
41
|
return {
|
|
21
42
|
config,
|
|
22
43
|
server,
|
|
23
44
|
sessions,
|
|
24
45
|
runStore,
|
|
25
46
|
controlPlane,
|
|
26
|
-
|
|
27
|
-
await Promise.all([closeServer(server), sessions.shutdown()]);
|
|
28
|
-
await runStore.flush();
|
|
29
|
-
}
|
|
47
|
+
close
|
|
30
48
|
};
|
|
31
49
|
}
|
|
32
50
|
function listen(server, port, host) {
|
package/dist/run-store.d.ts
CHANGED
package/dist/run-store.js
CHANGED
|
@@ -144,8 +144,17 @@ export class RunStore {
|
|
|
144
144
|
run.agentName.toLowerCase().includes(needle) ||
|
|
145
145
|
run.id.toLowerCase().includes(needle));
|
|
146
146
|
const total = matched.length;
|
|
147
|
+
const stats = { active: 0, completed: 0, failed: 0 };
|
|
148
|
+
for (const run of matched) {
|
|
149
|
+
if (isActive(run.state))
|
|
150
|
+
stats.active++;
|
|
151
|
+
if (run.state === 'completed')
|
|
152
|
+
stats.completed++;
|
|
153
|
+
if (run.state === 'failed')
|
|
154
|
+
stats.failed++;
|
|
155
|
+
}
|
|
147
156
|
const runs = matched.slice(0, limit).map(publicView);
|
|
148
|
-
return { runs, total };
|
|
157
|
+
return { runs, total, stats };
|
|
149
158
|
}
|
|
150
159
|
async flush() {
|
|
151
160
|
this.assertInitialized();
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import http from 'node:http';
|
|
|
3
3
|
import { AdminSessionStore } from './auth.js';
|
|
4
4
|
import { ControlPlaneError } from './control-plane.js';
|
|
5
5
|
import { RunNotFoundError, SessionNotFoundError, SessionRequestError } from './session.js';
|
|
6
|
-
import { redirectToAgentdWebUi, writeAgentdWebUi } from './webui/index.js';
|
|
6
|
+
import { redirectToAgentdWebUi, writeAgentdWebUi, writeAgentdWebUiModule } from './webui/index.js';
|
|
7
7
|
const ADMIN_COOKIE = 'agent_nexus_admin';
|
|
8
8
|
export function createAgentdServer(config, sessions, controlPlane) {
|
|
9
9
|
const adminSessions = new AdminSessionStore(config.adminSessionTtlMs || 12 * 60 * 60 * 1000);
|
|
@@ -72,6 +72,10 @@ async function handleRequest(context) {
|
|
|
72
72
|
writeAgentdWebUi(response);
|
|
73
73
|
return;
|
|
74
74
|
}
|
|
75
|
+
if (url.pathname.startsWith('/ui/') && request.method === 'GET') {
|
|
76
|
+
writeAgentdWebUiModule(response, url.pathname);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
75
79
|
if (url.pathname === '/health' && request.method === 'GET') {
|
|
76
80
|
writeJson(response, 200, {
|
|
77
81
|
ok: true,
|
|
@@ -220,8 +224,13 @@ async function handleRequest(context) {
|
|
|
220
224
|
assertAgentScope(principal, session.agentId);
|
|
221
225
|
assertJsonContentType(request);
|
|
222
226
|
const body = await readJsonBody(request, config.maxRequestBytes);
|
|
223
|
-
assertOnlyKeys(body, ['path']);
|
|
224
|
-
|
|
227
|
+
assertOnlyKeys(body, ['path', 'paths']);
|
|
228
|
+
const paths = normalizePublishPaths(body);
|
|
229
|
+
const result = await sessions.publishFile(sessionId, paths);
|
|
230
|
+
writeJson(response, 201, {
|
|
231
|
+
...result.session,
|
|
232
|
+
publishedArtifacts: result.artifacts
|
|
233
|
+
});
|
|
225
234
|
return;
|
|
226
235
|
}
|
|
227
236
|
const match = url.pathname.match(/^\/v1\/sessions\/([^/]+)(?:\/(message|cancel|events))?$/);
|
|
@@ -456,11 +465,12 @@ function streamEvents(request, response, sessions, sessionId, after, release) {
|
|
|
456
465
|
for (const event of sessions.eventsAfter(sessionId, after))
|
|
457
466
|
writeEvent(response, event);
|
|
458
467
|
const unsubscribe = sessions.subscribe(sessionId, (event) => writeEvent(response, event));
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
468
|
+
// Give idle SSE clients ~90 s to react before Node's default socket timeout
|
|
469
|
+
// trips. Combined with the writableEnded check inside the heartbeat this
|
|
470
|
+
// guarantees stuck keep-alive sessions eventually release their slot even
|
|
471
|
+
// if the peer never sends a FIN.
|
|
472
|
+
const idleMs = 90_000;
|
|
473
|
+
request.socket.setTimeout?.(idleMs);
|
|
464
474
|
let closed = false;
|
|
465
475
|
const close = () => {
|
|
466
476
|
if (closed)
|
|
@@ -468,10 +478,37 @@ function streamEvents(request, response, sessions, sessionId, after, release) {
|
|
|
468
478
|
closed = true;
|
|
469
479
|
clearInterval(heartbeat);
|
|
470
480
|
unsubscribe();
|
|
481
|
+
// Ensure both ends of the socket are actually torn down so the SSE
|
|
482
|
+
// slot returns to the pool even when the peer stops reading without
|
|
483
|
+
// closing the TCP connection cleanly.
|
|
484
|
+
try {
|
|
485
|
+
if (!response.writableEnded)
|
|
486
|
+
response.end();
|
|
487
|
+
}
|
|
488
|
+
catch { }
|
|
489
|
+
try {
|
|
490
|
+
request.socket.destroy();
|
|
491
|
+
}
|
|
492
|
+
catch { }
|
|
471
493
|
release();
|
|
472
494
|
};
|
|
495
|
+
const heartbeat = setInterval(() => {
|
|
496
|
+
if (response.destroyed || response.writableEnded) {
|
|
497
|
+
close();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
response.write(': heartbeat\n\n');
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
close();
|
|
505
|
+
}
|
|
506
|
+
}, 15_000);
|
|
507
|
+
heartbeat.unref?.();
|
|
473
508
|
request.once('close', close);
|
|
474
509
|
response.once('close', close);
|
|
510
|
+
request.socket.once('timeout', close);
|
|
511
|
+
request.socket.once('error', close);
|
|
475
512
|
}
|
|
476
513
|
function writeEvent(response, event) {
|
|
477
514
|
if (response.destroyed || response.writableEnded)
|
|
@@ -644,6 +681,19 @@ function optionalAttachmentIds(value) {
|
|
|
644
681
|
}
|
|
645
682
|
return value.map((item) => requiredString(item, 'attachments'));
|
|
646
683
|
}
|
|
684
|
+
function normalizePublishPaths(body) {
|
|
685
|
+
const bodyPaths = body.paths;
|
|
686
|
+
if (bodyPaths !== undefined) {
|
|
687
|
+
if (!Array.isArray(bodyPaths) || bodyPaths.length === 0) {
|
|
688
|
+
throw new RequestError(400, 'paths must be a non-empty array of strings');
|
|
689
|
+
}
|
|
690
|
+
if (bodyPaths.length > 32) {
|
|
691
|
+
throw new RequestError(400, 'paths accepts at most 32 entries');
|
|
692
|
+
}
|
|
693
|
+
return bodyPaths.map((item) => requiredString(item, 'paths'));
|
|
694
|
+
}
|
|
695
|
+
return [requiredString(body.path, 'path')];
|
|
696
|
+
}
|
|
647
697
|
function normalizedMediaType(value) {
|
|
648
698
|
const mediaType = stringHeader(value).split(';', 1)[0].trim().toLowerCase();
|
|
649
699
|
return mediaType || undefined;
|
|
@@ -18,6 +18,7 @@ export interface AcpSessionSink extends AgentSessionSink {
|
|
|
18
18
|
setAcpSessionId(id: string): void;
|
|
19
19
|
}
|
|
20
20
|
export interface AgentSessionRuntime {
|
|
21
|
+
isAvailable?(): boolean;
|
|
21
22
|
start(workspace?: string): Promise<void>;
|
|
22
23
|
prompt(message: string, attachments?: AgentdInputAttachment[]): Promise<void>;
|
|
23
24
|
respondPending(response: AgentdPendingResponse | string, attachments?: AgentdInputAttachment[]): Promise<void>;
|
package/dist/session.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { SessionEventLog } from './events.js';
|
|
|
3
3
|
import { RunStore, type RunListQuery } from './run-store.js';
|
|
4
4
|
import type { AcpSessionSink, AgentSessionRuntime, AgentSessionSink } from './session-contract.js';
|
|
5
5
|
import type { AgentdAgentView, AgentdArtifact, AgentdConfig, AgentdEvent, AgentdEventType, AgentdInputAttachmentView, AgentdPendingRequest, AgentdPendingResponse, AgentdProtocol, AgentdSessionState, AgentdSessionView, AgentdTurnCompletionProof } from './types.js';
|
|
6
|
-
import type
|
|
6
|
+
import { type WorkspacePolicy } from './workspace.js';
|
|
7
7
|
export declare class ManagedSession implements AgentSessionSink, AcpSessionSink {
|
|
8
8
|
readonly agentId: string;
|
|
9
9
|
readonly protocol: AgentdProtocol;
|
|
@@ -37,6 +37,8 @@ export declare class ManagedSession implements AgentSessionSink, AcpSessionSink
|
|
|
37
37
|
replaceOutput(text: string): void;
|
|
38
38
|
addArtifact(artifact: AgentdArtifact): void;
|
|
39
39
|
publishFile(filePath: string): Promise<AgentdArtifact>;
|
|
40
|
+
publishFiles(filePaths: string[]): Promise<AgentdArtifact[]>;
|
|
41
|
+
private publishSingleFile;
|
|
40
42
|
setPending(request: AgentdPendingRequest): void;
|
|
41
43
|
clearPending(): void;
|
|
42
44
|
emit(type: AgentdEventType, data?: unknown): void;
|
|
@@ -58,6 +60,7 @@ export declare class SessionManager {
|
|
|
58
60
|
private readonly runStore?;
|
|
59
61
|
readonly instanceId: `${string}-${string}-${string}-${string}-${string}`;
|
|
60
62
|
private sessions;
|
|
63
|
+
private sessionLocks;
|
|
61
64
|
private cleanupTimer?;
|
|
62
65
|
private readinessCache?;
|
|
63
66
|
constructor(config: AgentdConfig, workspacePolicy: WorkspacePolicy, drivers: Map<string, AgentDriver>, runStore?: RunStore | undefined);
|
|
@@ -71,17 +74,26 @@ export declare class SessionManager {
|
|
|
71
74
|
listRuns(query?: RunListQuery): {
|
|
72
75
|
runs: import("./types.js").AgentdRunView[];
|
|
73
76
|
total: number;
|
|
77
|
+
stats: {
|
|
78
|
+
active: number;
|
|
79
|
+
completed: number;
|
|
80
|
+
failed: number;
|
|
81
|
+
};
|
|
74
82
|
};
|
|
75
83
|
getRun(id: string): import("./types.js").AgentdRunDetail;
|
|
76
84
|
message(id: string, message: string, attachmentIds?: string[]): Promise<AgentdSessionView>;
|
|
77
85
|
resolvePending(id: string, response: AgentdPendingResponse, attachmentIds?: string[]): Promise<AgentdSessionView>;
|
|
78
86
|
addInputAttachment(id: string, name: string, mediaType: string | undefined, bytes: Buffer): AgentdInputAttachmentView;
|
|
79
|
-
publishFile(id: string,
|
|
87
|
+
publishFile(id: string, paths: string[]): Promise<{
|
|
88
|
+
session: AgentdSessionView;
|
|
89
|
+
artifacts: AgentdArtifact[];
|
|
90
|
+
}>;
|
|
80
91
|
cancel(id: string): Promise<AgentdSessionView>;
|
|
81
92
|
close(id: string): Promise<AgentdSessionView>;
|
|
82
93
|
eventsAfter(id: string, after?: string): AgentdEvent[];
|
|
83
94
|
subscribe(id: string, listener: (event: AgentdEvent) => void): () => boolean;
|
|
84
95
|
shutdown(): Promise<void>;
|
|
96
|
+
private withSessionLock;
|
|
85
97
|
private require;
|
|
86
98
|
private ensureCapacity;
|
|
87
99
|
private cleanup;
|
package/dist/session.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { extname } from 'node:path';
|
|
4
4
|
import { AcpProcessRuntime } from './acp/runtime.js';
|
|
5
5
|
import { A2AClientRuntime, probeA2AAgent } from './a2a/runtime.js';
|
|
6
6
|
import { SessionEventLog } from './events.js';
|
|
7
|
+
import { ContainedReadError, readContainedFile } from './workspace.js';
|
|
7
8
|
const MAX_SESSION_ARTIFACTS = 64;
|
|
8
9
|
const MAX_ARTIFACT_BASE64_CHARS = 16 * 1024 * 1024;
|
|
9
10
|
const MAX_SESSION_BASE64_CHARS = 32 * 1024 * 1024;
|
|
@@ -167,47 +168,47 @@ export class ManagedSession {
|
|
|
167
168
|
});
|
|
168
169
|
}
|
|
169
170
|
async publishFile(filePath) {
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
return (await this.publishFiles([filePath]))[0];
|
|
172
|
+
}
|
|
173
|
+
async publishFiles(filePaths) {
|
|
174
|
+
const requested = filePaths
|
|
175
|
+
.map((value) => String(value || '').trim())
|
|
176
|
+
.filter(Boolean);
|
|
177
|
+
if (!requested.length) {
|
|
172
178
|
throw new SessionRequestError(400, 'path is required');
|
|
173
179
|
}
|
|
180
|
+
if (requested.length > 32) {
|
|
181
|
+
throw new SessionRequestError(400, 'A single publish request accepts at most 32 files');
|
|
182
|
+
}
|
|
174
183
|
if (!this.workspace) {
|
|
175
184
|
throw new SessionRequestError(400, 'This session does not have a local workspace');
|
|
176
185
|
}
|
|
177
|
-
|
|
186
|
+
const seen = new Set();
|
|
187
|
+
const artifacts = [];
|
|
188
|
+
for (const requestedPath of requested) {
|
|
189
|
+
if (seen.has(requestedPath))
|
|
190
|
+
continue;
|
|
191
|
+
seen.add(requestedPath);
|
|
192
|
+
artifacts.push(await this.publishSingleFile(requestedPath));
|
|
193
|
+
}
|
|
194
|
+
return artifacts;
|
|
195
|
+
}
|
|
196
|
+
async publishSingleFile(requestedPath) {
|
|
178
197
|
try {
|
|
179
198
|
const workspaceRoot = await realpath(this.workspace);
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
handle = await open(target, 'r');
|
|
184
|
-
// Resolve again after opening so a swapped symlink cannot silently
|
|
185
|
-
// redirect a publish request outside the configured workspace.
|
|
186
|
-
const verifiedTarget = await realpath(unresolved);
|
|
187
|
-
assertWorkspacePath(workspaceRoot, verifiedTarget);
|
|
188
|
-
if (verifiedTarget !== target) {
|
|
189
|
-
throw new SessionRequestError(409, 'Published file changed while it was being opened');
|
|
190
|
-
}
|
|
191
|
-
const stats = await handle.stat();
|
|
192
|
-
if (!stats.isFile()) {
|
|
193
|
-
throw new SessionRequestError(400, 'Only regular files can be published');
|
|
194
|
-
}
|
|
195
|
-
if (stats.size > MAX_PUBLISHED_FILE_BYTES) {
|
|
196
|
-
throw new SessionRequestError(413, `Published file exceeds the ${MAX_PUBLISHED_FILE_BYTES} byte limit`);
|
|
197
|
-
}
|
|
198
|
-
const bytes = await handle.readFile();
|
|
199
|
-
const filename = basename(verifiedTarget);
|
|
200
|
-
const workspacePath = relative(workspaceRoot, verifiedTarget).split(sep).join('/');
|
|
199
|
+
const file = await readContainedFile([workspaceRoot], requestedPath, {
|
|
200
|
+
maxBytes: MAX_PUBLISHED_FILE_BYTES
|
|
201
|
+
});
|
|
201
202
|
const artifact = {
|
|
202
203
|
id: `published:${randomUUID()}`,
|
|
203
|
-
name: filename,
|
|
204
|
-
filename,
|
|
205
|
-
mediaType: mediaTypeForPath(filename),
|
|
206
|
-
bytesBase64: bytes.toString('base64'),
|
|
204
|
+
name: file.filename,
|
|
205
|
+
filename: file.filename,
|
|
206
|
+
mediaType: mediaTypeForPath(file.filename),
|
|
207
|
+
bytesBase64: file.bytes.toString('base64'),
|
|
207
208
|
metadata: {
|
|
208
209
|
source: 'workspace_publish',
|
|
209
|
-
path:
|
|
210
|
-
size:
|
|
210
|
+
path: file.relative,
|
|
211
|
+
size: file.size
|
|
211
212
|
}
|
|
212
213
|
};
|
|
213
214
|
this.addArtifact(artifact);
|
|
@@ -217,6 +218,8 @@ export class ManagedSession {
|
|
|
217
218
|
catch (error) {
|
|
218
219
|
if (error instanceof SessionRequestError)
|
|
219
220
|
throw error;
|
|
221
|
+
if (error instanceof ContainedReadError)
|
|
222
|
+
throw publishFailure(error);
|
|
220
223
|
const code = error?.code;
|
|
221
224
|
if (code === 'ENOENT') {
|
|
222
225
|
throw new SessionRequestError(404, 'Published file was not found');
|
|
@@ -226,9 +229,6 @@ export class ManagedSession {
|
|
|
226
229
|
}
|
|
227
230
|
throw error;
|
|
228
231
|
}
|
|
229
|
-
finally {
|
|
230
|
-
await handle?.close();
|
|
231
|
-
}
|
|
232
232
|
}
|
|
233
233
|
setPending(request) {
|
|
234
234
|
this.completion = undefined;
|
|
@@ -278,7 +278,7 @@ export class ManagedSession {
|
|
|
278
278
|
return attachmentView(attachment);
|
|
279
279
|
}
|
|
280
280
|
async message(message, attachmentIds = []) {
|
|
281
|
-
if (!this.runtime)
|
|
281
|
+
if (!this.runtime || this.runtime.isAvailable?.() === false)
|
|
282
282
|
throw new SessionRequestError(409, 'Agent runtime is unavailable');
|
|
283
283
|
const attachments = this.inputAttachmentsFor(attachmentIds);
|
|
284
284
|
if (this.pendingRequest) {
|
|
@@ -307,13 +307,14 @@ export class ManagedSession {
|
|
|
307
307
|
task: message,
|
|
308
308
|
inputAttachmentCount: attachments.length
|
|
309
309
|
}).id;
|
|
310
|
+
this.setState('running');
|
|
310
311
|
void this.runtime.prompt(message, attachments).catch((error) => {
|
|
311
312
|
if (this.state !== 'canceled')
|
|
312
313
|
this.setState('failed', errorMessage(error));
|
|
313
314
|
});
|
|
314
315
|
}
|
|
315
316
|
async resolvePending(response, attachmentIds = []) {
|
|
316
|
-
if (!this.runtime) {
|
|
317
|
+
if (!this.runtime || this.runtime.isAvailable?.() === false) {
|
|
317
318
|
throw new SessionRequestError(409, 'Agent runtime is unavailable');
|
|
318
319
|
}
|
|
319
320
|
const pending = this.pendingRequest;
|
|
@@ -330,11 +331,31 @@ export class ManagedSession {
|
|
|
330
331
|
await this.runtime.respondPending(response, attachments);
|
|
331
332
|
}
|
|
332
333
|
async cancel() {
|
|
333
|
-
|
|
334
|
+
const runtime = this.runtime;
|
|
335
|
+
this.runtime = undefined;
|
|
336
|
+
this.clearPending();
|
|
337
|
+
this.setState('canceled');
|
|
338
|
+
try {
|
|
339
|
+
await runtime?.cancel();
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
try {
|
|
343
|
+
await runtime?.dispose();
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
this.inputAttachments.clear();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
334
349
|
}
|
|
335
350
|
async dispose() {
|
|
336
|
-
|
|
337
|
-
this.
|
|
351
|
+
const runtime = this.runtime;
|
|
352
|
+
this.runtime = undefined;
|
|
353
|
+
try {
|
|
354
|
+
await runtime?.dispose();
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
this.inputAttachments.clear();
|
|
358
|
+
}
|
|
338
359
|
}
|
|
339
360
|
snapshot() {
|
|
340
361
|
return {
|
|
@@ -399,6 +420,7 @@ export class SessionManager {
|
|
|
399
420
|
runStore;
|
|
400
421
|
instanceId = randomUUID();
|
|
401
422
|
sessions = new Map();
|
|
423
|
+
sessionLocks = new Map();
|
|
402
424
|
cleanupTimer;
|
|
403
425
|
readinessCache;
|
|
404
426
|
constructor(config, workspacePolicy, drivers, runStore) {
|
|
@@ -515,7 +537,7 @@ export class SessionManager {
|
|
|
515
537
|
const session = new ManagedSession(agentId, config.protocol === 'a2a' ? 'a2a' : 'acp', workspace, ownerKeyId, this.config.maxEventsPerSession, this.config.maxOutputChars, config.name || agentId, this.runStore, this.instanceId);
|
|
516
538
|
const runtime = config.protocol === 'a2a'
|
|
517
539
|
? new A2AClientRuntime(config, session, this.config.promptTimeoutMs || 30 * 60_000)
|
|
518
|
-
: new AcpProcessRuntime(driver, session, this.config.maxOutputChars, this.config.promptTimeoutMs || 30 * 60_000);
|
|
540
|
+
: new AcpProcessRuntime(driver, session, this.config.maxOutputChars, this.config.promptTimeoutMs || 30 * 60_000, this.workspacePolicy.listRoots());
|
|
519
541
|
this.sessions.set(session.id, session);
|
|
520
542
|
session.attach(runtime);
|
|
521
543
|
try {
|
|
@@ -524,8 +546,15 @@ export class SessionManager {
|
|
|
524
546
|
}
|
|
525
547
|
catch (error) {
|
|
526
548
|
session.setState('failed', errorMessage(error));
|
|
527
|
-
|
|
528
|
-
|
|
549
|
+
try {
|
|
550
|
+
await session.dispose();
|
|
551
|
+
}
|
|
552
|
+
catch (cleanupError) {
|
|
553
|
+
console.error(JSON.stringify({ level: 'error', event: 'session_start_cleanup_failed', sessionId: session.id, message: errorMessage(cleanupError) }));
|
|
554
|
+
}
|
|
555
|
+
finally {
|
|
556
|
+
this.sessions.delete(session.id);
|
|
557
|
+
}
|
|
529
558
|
throw new SessionRequestError(502, `Agent failed to start: ${errorMessage(error)}`);
|
|
530
559
|
}
|
|
531
560
|
}
|
|
@@ -539,7 +568,7 @@ export class SessionManager {
|
|
|
539
568
|
return this.sessions.size;
|
|
540
569
|
}
|
|
541
570
|
listRuns(query = {}) {
|
|
542
|
-
return this.runStore?.list(query) || { runs: [], total: 0 };
|
|
571
|
+
return this.runStore?.list(query) || { runs: [], total: 0, stats: { active: 0, completed: 0, failed: 0 } };
|
|
543
572
|
}
|
|
544
573
|
getRun(id) {
|
|
545
574
|
const run = this.runStore?.get(id);
|
|
@@ -551,37 +580,47 @@ export class SessionManager {
|
|
|
551
580
|
const text = String(message || '');
|
|
552
581
|
if (!text.trim())
|
|
553
582
|
throw new SessionRequestError(400, 'message is required');
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
583
|
+
return this.withSessionLock(id, async () => {
|
|
584
|
+
const session = this.require(id);
|
|
585
|
+
await session.message(text, attachmentIds);
|
|
586
|
+
return session.snapshot();
|
|
587
|
+
});
|
|
557
588
|
}
|
|
558
589
|
async resolvePending(id, response, attachmentIds = []) {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
590
|
+
return this.withSessionLock(id, async () => {
|
|
591
|
+
const session = this.require(id);
|
|
592
|
+
await session.resolvePending(response, attachmentIds);
|
|
593
|
+
return session.snapshot();
|
|
594
|
+
});
|
|
562
595
|
}
|
|
563
596
|
addInputAttachment(id, name, mediaType, bytes) {
|
|
564
597
|
const session = this.require(id);
|
|
565
598
|
return session.addInputAttachment(name, mediaType, bytes);
|
|
566
599
|
}
|
|
567
|
-
async publishFile(id,
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
600
|
+
async publishFile(id, paths) {
|
|
601
|
+
return this.withSessionLock(id, async () => {
|
|
602
|
+
const session = this.require(id);
|
|
603
|
+
const artifacts = await session.publishFiles(paths);
|
|
604
|
+
return { session: session.snapshot(), artifacts };
|
|
605
|
+
});
|
|
571
606
|
}
|
|
572
607
|
async cancel(id) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
608
|
+
return this.withSessionLock(id, async () => {
|
|
609
|
+
const session = this.require(id);
|
|
610
|
+
await session.cancel();
|
|
611
|
+
return session.snapshot();
|
|
612
|
+
});
|
|
576
613
|
}
|
|
577
614
|
async close(id) {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
615
|
+
return this.withSessionLock(id, async () => {
|
|
616
|
+
const session = this.require(id);
|
|
617
|
+
if (isActive(session.state))
|
|
618
|
+
await session.cancel();
|
|
619
|
+
const snapshot = session.snapshot();
|
|
620
|
+
await session.dispose();
|
|
621
|
+
this.sessions.delete(id);
|
|
622
|
+
return snapshot;
|
|
623
|
+
});
|
|
585
624
|
}
|
|
586
625
|
eventsAfter(id, after) {
|
|
587
626
|
return this.require(id).events.after(after);
|
|
@@ -599,6 +638,28 @@ export class SessionManager {
|
|
|
599
638
|
}));
|
|
600
639
|
this.sessions.clear();
|
|
601
640
|
}
|
|
641
|
+
async withSessionLock(id, operation) {
|
|
642
|
+
// Serialise mutating operations per session so that concurrent
|
|
643
|
+
// message / resolvePending / cancel / close / publish requests cannot
|
|
644
|
+
// observe half-updated state (e.g. artifacts reset in the middle of
|
|
645
|
+
// publishFile).
|
|
646
|
+
const previous = this.sessionLocks.get(id) || Promise.resolve();
|
|
647
|
+
let release;
|
|
648
|
+
const gate = new Promise((resolve) => {
|
|
649
|
+
release = resolve;
|
|
650
|
+
});
|
|
651
|
+
const tail = previous.then(() => gate);
|
|
652
|
+
this.sessionLocks.set(id, tail);
|
|
653
|
+
try {
|
|
654
|
+
await previous;
|
|
655
|
+
return await operation();
|
|
656
|
+
}
|
|
657
|
+
finally {
|
|
658
|
+
release();
|
|
659
|
+
if (this.sessionLocks.get(id) === tail)
|
|
660
|
+
this.sessionLocks.delete(id);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
602
663
|
require(id) {
|
|
603
664
|
const session = this.sessions.get(id);
|
|
604
665
|
if (!session)
|
|
@@ -626,17 +687,29 @@ export class SessionManager {
|
|
|
626
687
|
for (const [id, session] of this.sessions) {
|
|
627
688
|
if (session.updatedAt > cutoff)
|
|
628
689
|
continue;
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
690
|
+
await this.withSessionLock(id, async () => {
|
|
691
|
+
if (this.sessions.get(id) !== session || session.updatedAt > cutoff)
|
|
692
|
+
return;
|
|
693
|
+
if (!isTerminal(session.state))
|
|
694
|
+
session.setState('failed', 'Session expired after exceeding its lifetime');
|
|
695
|
+
try {
|
|
696
|
+
await session.dispose();
|
|
697
|
+
}
|
|
698
|
+
catch (error) {
|
|
699
|
+
console.error(JSON.stringify({ level: 'error', event: 'session_cleanup_failed', sessionId: id, message: errorMessage(error) }));
|
|
700
|
+
}
|
|
701
|
+
finally {
|
|
702
|
+
this.sessions.delete(id);
|
|
703
|
+
}
|
|
704
|
+
});
|
|
634
705
|
}
|
|
635
706
|
}
|
|
636
707
|
restartCleanup() {
|
|
637
708
|
if (this.cleanupTimer)
|
|
638
709
|
clearInterval(this.cleanupTimer);
|
|
639
|
-
this.cleanupTimer = setInterval(() => void this.cleanup()
|
|
710
|
+
this.cleanupTimer = setInterval(() => void this.cleanup().catch((error) => {
|
|
711
|
+
console.error(JSON.stringify({ level: 'error', event: 'session_cleanup_failed', message: errorMessage(error) }));
|
|
712
|
+
}), this.config.cleanupIntervalMs || 60_000);
|
|
640
713
|
this.cleanupTimer.unref?.();
|
|
641
714
|
}
|
|
642
715
|
defaultWorkspace(agentId) {
|
|
@@ -694,10 +767,16 @@ function runArtifact(artifact) {
|
|
|
694
767
|
metadata: artifact.metadata ? structuredClone(artifact.metadata) : undefined
|
|
695
768
|
};
|
|
696
769
|
}
|
|
697
|
-
function
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
770
|
+
function publishFailure(error) {
|
|
771
|
+
switch (error.failure) {
|
|
772
|
+
case 'swapped':
|
|
773
|
+
return new SessionRequestError(409, 'Published file changed while it was being opened');
|
|
774
|
+
case 'not-a-file':
|
|
775
|
+
return new SessionRequestError(400, 'Only regular files can be published');
|
|
776
|
+
case 'too-large':
|
|
777
|
+
return new SessionRequestError(413, `Published file exceeds the ${MAX_PUBLISHED_FILE_BYTES} byte limit`);
|
|
778
|
+
case 'outside':
|
|
779
|
+
return new SessionRequestError(403, 'Published files must stay inside the session workspace');
|
|
701
780
|
}
|
|
702
781
|
}
|
|
703
782
|
function mediaTypeForPath(path) {
|