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.
Files changed (61) hide show
  1. package/README.md +359 -250
  2. package/dist/a2a/runtime.d.ts +7 -1
  3. package/dist/a2a/runtime.js +290 -17
  4. package/dist/acp/runtime.d.ts +5 -1
  5. package/dist/acp/runtime.js +88 -39
  6. package/dist/artifact-store.d.ts +162 -0
  7. package/dist/artifact-store.js +1081 -0
  8. package/dist/cli.js +13 -14
  9. package/dist/completion-contract.js +1 -1
  10. package/dist/config.js +45 -5
  11. package/dist/control-plane-validation.d.ts +54 -0
  12. package/dist/control-plane-validation.js +152 -0
  13. package/dist/control-plane.d.ts +9 -32
  14. package/dist/control-plane.js +52 -158
  15. package/dist/drivers/index.js +12 -0
  16. package/dist/drivers/pi.js +1 -1
  17. package/dist/drivers/stdio.d.ts +4 -1
  18. package/dist/drivers/stdio.js +135 -19
  19. package/dist/events.d.ts +9 -0
  20. package/dist/events.js +17 -4
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +33 -6
  23. package/dist/process-tree.js +15 -6
  24. package/dist/run-store.d.ts +57 -7
  25. package/dist/run-store.js +188 -23
  26. package/dist/server/auth-policy.d.ts +27 -0
  27. package/dist/server/auth-policy.js +143 -0
  28. package/dist/server/connection.d.ts +8 -0
  29. package/dist/server/connection.js +67 -0
  30. package/dist/server/diagnostics.d.ts +40 -0
  31. package/dist/server/diagnostics.js +130 -0
  32. package/dist/server/metrics.d.ts +51 -0
  33. package/dist/server/metrics.js +56 -0
  34. package/dist/server/quota.d.ts +59 -0
  35. package/dist/server/quota.js +176 -0
  36. package/dist/server/validation.d.ts +29 -0
  37. package/dist/server/validation.js +221 -0
  38. package/dist/server.d.ts +3 -0
  39. package/dist/server.js +682 -418
  40. package/dist/session/admin-runs.d.ts +73 -0
  41. package/dist/session/admin-runs.js +297 -0
  42. package/dist/session/admission.d.ts +32 -0
  43. package/dist/session/admission.js +62 -0
  44. package/dist/session/readiness.d.ts +33 -0
  45. package/dist/session/readiness.js +174 -0
  46. package/dist/session-contract.d.ts +1 -0
  47. package/dist/session.d.ts +64 -7
  48. package/dist/session.js +321 -159
  49. package/dist/sse-writer.d.ts +7 -0
  50. package/dist/sse-writer.js +60 -0
  51. package/dist/types.d.ts +44 -2
  52. package/dist/types.js +1 -0
  53. package/dist/webui/app/sources.js +18 -10
  54. package/dist/webui/index.js +11 -11
  55. package/dist/webui/markup.js +69 -66
  56. package/dist/webui/styles.js +791 -616
  57. package/dist/workspace.d.ts +30 -0
  58. package/dist/workspace.js +67 -2
  59. package/examples/client.mjs +102 -0
  60. package/nexus-agentd.example.json +21 -2
  61. package/package.json +6 -3
package/dist/session.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { open, realpath } from 'node:fs/promises';
3
- import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path';
2
+ import { realpath } from 'node:fs/promises';
3
+ import { extname } from 'node:path';
4
4
  import { AcpProcessRuntime } from './acp/runtime.js';
5
- import { A2AClientRuntime, probeA2AAgent } from './a2a/runtime.js';
5
+ import { A2AClientRuntime } from './a2a/runtime.js';
6
6
  import { SessionEventLog } from './events.js';
7
+ import { AdminRunManager } from './session/admin-runs.js';
8
+ import { SessionAdmission } from './session/admission.js';
9
+ import { SessionReadiness } from './session/readiness.js';
10
+ import { ContainedReadError, readContainedFile } from './workspace.js';
7
11
  const MAX_SESSION_ARTIFACTS = 64;
8
12
  const MAX_ARTIFACT_BASE64_CHARS = 16 * 1024 * 1024;
9
13
  const MAX_SESSION_BASE64_CHARS = 32 * 1024 * 1024;
10
- const READINESS_CACHE_MS = 20_000;
11
14
  const MAX_INPUT_ATTACHMENTS = 16;
12
15
  const MAX_INPUT_ATTACHMENT_BYTES = 16 * 1024 * 1024;
13
16
  const MAX_SESSION_INPUT_BYTES = 32 * 1024 * 1024;
@@ -35,7 +38,9 @@ export class ManagedSession {
35
38
  artifacts = [];
36
39
  inputAttachments = new Map();
37
40
  runtime;
38
- constructor(agentId, protocol, workspace, ownerKeyId, maxEvents, maxOutputChars, agentName, runStore, instanceId = randomUUID()) {
41
+ artifactStore;
42
+ onRunUpdate;
43
+ constructor(agentId, protocol, workspace, ownerKeyId, maxEvents, maxOutputChars, agentName, runStore, instanceId = randomUUID(), artifactStore, onRunUpdate) {
39
44
  this.agentId = agentId;
40
45
  this.protocol = protocol;
41
46
  this.workspace = workspace;
@@ -44,12 +49,17 @@ export class ManagedSession {
44
49
  this.agentName = agentName;
45
50
  this.runStore = runStore;
46
51
  this.instanceId = instanceId;
52
+ this.artifactStore = artifactStore || runStore;
53
+ this.onRunUpdate = onRunUpdate;
47
54
  this.events = new SessionEventLog(this.id, maxEvents);
48
55
  this.events.append('session_state', { state: this.state, protocol });
49
56
  }
50
57
  attach(runtime) {
51
58
  this.runtime = runtime;
52
59
  }
60
+ runtimeAvailable() {
61
+ return Boolean(this.runtime && this.runtime.isAvailable?.() !== false);
62
+ }
53
63
  setProtocolSessionId(id) {
54
64
  this.protocolSessionId = id;
55
65
  this.updatedAt = Date.now();
@@ -193,40 +203,21 @@ export class ManagedSession {
193
203
  return artifacts;
194
204
  }
195
205
  async publishSingleFile(requestedPath) {
196
- let handle;
197
206
  try {
198
207
  const workspaceRoot = await realpath(this.workspace);
199
- const unresolved = resolve(workspaceRoot, requestedPath);
200
- const target = await realpath(unresolved);
201
- assertWorkspacePath(workspaceRoot, target);
202
- handle = await open(target, 'r');
203
- // Resolve again after opening so a swapped symlink cannot silently
204
- // redirect a publish request outside the configured workspace.
205
- const verifiedTarget = await realpath(unresolved);
206
- assertWorkspacePath(workspaceRoot, verifiedTarget);
207
- if (verifiedTarget !== target) {
208
- throw new SessionRequestError(409, 'Published file changed while it was being opened');
209
- }
210
- const stats = await handle.stat();
211
- if (!stats.isFile()) {
212
- throw new SessionRequestError(400, 'Only regular files can be published');
213
- }
214
- if (stats.size > MAX_PUBLISHED_FILE_BYTES) {
215
- throw new SessionRequestError(413, `Published file exceeds the ${MAX_PUBLISHED_FILE_BYTES} byte limit`);
216
- }
217
- const bytes = await handle.readFile();
218
- const filename = basename(verifiedTarget);
219
- const workspacePath = relative(workspaceRoot, verifiedTarget).split(sep).join('/');
208
+ const file = await readContainedFile([workspaceRoot], requestedPath, {
209
+ maxBytes: MAX_PUBLISHED_FILE_BYTES
210
+ });
220
211
  const artifact = {
221
212
  id: `published:${randomUUID()}`,
222
- name: filename,
223
- filename,
224
- mediaType: mediaTypeForPath(filename),
225
- bytesBase64: bytes.toString('base64'),
213
+ name: file.filename,
214
+ filename: file.filename,
215
+ mediaType: mediaTypeForPath(file.filename),
216
+ bytesBase64: file.bytes.toString('base64'),
226
217
  metadata: {
227
218
  source: 'workspace_publish',
228
- path: workspacePath,
229
- size: bytes.length
219
+ path: file.relative,
220
+ size: file.size
230
221
  }
231
222
  };
232
223
  this.addArtifact(artifact);
@@ -236,6 +227,8 @@ export class ManagedSession {
236
227
  catch (error) {
237
228
  if (error instanceof SessionRequestError)
238
229
  throw error;
230
+ if (error instanceof ContainedReadError)
231
+ throw publishFailure(error);
239
232
  const code = error?.code;
240
233
  if (code === 'ENOENT') {
241
234
  throw new SessionRequestError(404, 'Published file was not found');
@@ -245,9 +238,6 @@ export class ManagedSession {
245
238
  }
246
239
  throw error;
247
240
  }
248
- finally {
249
- await handle?.close();
250
- }
251
241
  }
252
242
  setPending(request) {
253
243
  this.completion = undefined;
@@ -296,8 +286,8 @@ export class ManagedSession {
296
286
  this.updatedAt = Date.now();
297
287
  return attachmentView(attachment);
298
288
  }
299
- async message(message, attachmentIds = []) {
300
- if (!this.runtime)
289
+ async message(message, attachmentIds = [], retryOfRunId) {
290
+ if (!this.runtime || this.runtime.isAvailable?.() === false)
301
291
  throw new SessionRequestError(409, 'Agent runtime is unavailable');
302
292
  const attachments = this.inputAttachmentsFor(attachmentIds);
303
293
  if (this.pendingRequest) {
@@ -324,15 +314,17 @@ export class ManagedSession {
324
314
  protocolSessionId: this.protocolSessionId,
325
315
  ownerKeyId: this.ownerKeyId,
326
316
  task: message,
327
- inputAttachmentCount: attachments.length
317
+ inputAttachmentCount: attachments.length,
318
+ ...(retryOfRunId ? { retryOfRunId } : {})
328
319
  }).id;
320
+ this.setState('running');
329
321
  void this.runtime.prompt(message, attachments).catch((error) => {
330
322
  if (this.state !== 'canceled')
331
323
  this.setState('failed', errorMessage(error));
332
324
  });
333
325
  }
334
326
  async resolvePending(response, attachmentIds = []) {
335
- if (!this.runtime) {
327
+ if (!this.runtime || this.runtime.isAvailable?.() === false) {
336
328
  throw new SessionRequestError(409, 'Agent runtime is unavailable');
337
329
  }
338
330
  const pending = this.pendingRequest;
@@ -349,11 +341,31 @@ export class ManagedSession {
349
341
  await this.runtime.respondPending(response, attachments);
350
342
  }
351
343
  async cancel() {
352
- await this.runtime?.cancel();
344
+ const runtime = this.runtime;
345
+ this.runtime = undefined;
346
+ this.clearPending();
347
+ this.setState('canceled');
348
+ try {
349
+ await runtime?.cancel();
350
+ }
351
+ finally {
352
+ try {
353
+ await runtime?.dispose();
354
+ }
355
+ finally {
356
+ this.inputAttachments.clear();
357
+ }
358
+ }
353
359
  }
354
360
  async dispose() {
355
- await this.runtime?.dispose();
356
- this.inputAttachments.clear();
361
+ const runtime = this.runtime;
362
+ this.runtime = undefined;
363
+ try {
364
+ await runtime?.dispose();
365
+ }
366
+ finally {
367
+ this.inputAttachments.clear();
368
+ }
357
369
  }
358
370
  snapshot() {
359
371
  return {
@@ -401,6 +413,34 @@ export class ManagedSession {
401
413
  updatedAt: this.updatedAt,
402
414
  ...patch
403
415
  });
416
+ if (patch.artifacts && this.artifactStore?.recordArtifacts) {
417
+ const runId = this.currentRunId;
418
+ const artifacts = structuredClone(this.artifacts);
419
+ void Promise.resolve()
420
+ .then(() => this.artifactStore.recordArtifacts(runId, artifacts))
421
+ .catch((error) => {
422
+ console.error(JSON.stringify({
423
+ level: 'error',
424
+ event: 'artifact_snapshot_record_failed',
425
+ runId,
426
+ message: errorMessage(error)
427
+ }));
428
+ });
429
+ }
430
+ try {
431
+ this.onRunUpdate?.(this.currentRunId, this.state);
432
+ }
433
+ catch (error) {
434
+ // Lifecycle observers (for example quota accounting) must never
435
+ // turn a successful agent event into a failed protocol turn.
436
+ console.error(JSON.stringify({
437
+ level: 'error',
438
+ event: 'session_lifecycle_observer_failed',
439
+ sessionId: this.id,
440
+ runId: this.currentRunId,
441
+ message: errorMessage(error)
442
+ }));
443
+ }
404
444
  }
405
445
  inputAttachmentsFor(ids) {
406
446
  const unique = Array.from(new Set(ids));
@@ -420,18 +460,83 @@ export class SessionManager {
420
460
  sessions = new Map();
421
461
  sessionLocks = new Map();
422
462
  cleanupTimer;
423
- readinessCache;
424
- constructor(config, workspacePolicy, drivers, runStore) {
463
+ readiness;
464
+ admission;
465
+ adminRuns;
466
+ artifactStore;
467
+ lifecycleListeners = new Set();
468
+ constructor(config, workspacePolicy, drivers, runStore, artifactStore) {
425
469
  this.config = config;
426
470
  this.workspacePolicy = workspacePolicy;
427
471
  this.drivers = drivers;
428
472
  this.runStore = runStore;
473
+ this.artifactStore = artifactStore || runStore;
474
+ this.readiness = new SessionReadiness(config, drivers, {
475
+ requestError: (status, message) => new SessionRequestError(status, message)
476
+ });
477
+ this.admission = new SessionAdmission({
478
+ maxSessions: () => this.config.maxSessions || 64,
479
+ sessionCount: () => this.sessions.size,
480
+ sessions: () => this.sessions.values(),
481
+ isCurrent: (session) => this.sessions.get(session.id) === session,
482
+ withSessionLock: (id, operation) => this.withSessionLock(id, operation),
483
+ removeSession: (id) => {
484
+ const session = this.sessions.get(id);
485
+ if (!session || !this.sessions.delete(id))
486
+ return;
487
+ this.notifyLifecycle({
488
+ type: 'session_closed',
489
+ sessionId: session.id,
490
+ ownerKeyId: session.ownerKeyId,
491
+ agentId: session.agentId
492
+ });
493
+ },
494
+ requestError: (status, message) => new SessionRequestError(status, message)
495
+ });
496
+ this.adminRuns = new AdminRunManager({
497
+ getRun: (runId) => this.getRun(runId),
498
+ getSession: (sessionId) => this.sessions.get(sessionId),
499
+ getAgent: (agentId) => this.config.agents[agentId],
500
+ getOwnerKeyId: (runId) => this.getRunOwnerKeyId(runId),
501
+ withSessionLock: (sessionId, operation) => this.withSessionLock(sessionId, operation),
502
+ startRetry: async (run, ownerKeyId, reservation) => {
503
+ const created = await this.create(run.agentId, run.workspace, ownerKeyId);
504
+ try {
505
+ reservation?.bind(created.id);
506
+ await this.message(created.id, run.task, [], run.id);
507
+ const snapshot = this.get(created.id);
508
+ if (!snapshot.runId) {
509
+ throw new SessionRequestError(502, 'Retried task did not create a run record');
510
+ }
511
+ return { runId: snapshot.runId, sessionId: created.id };
512
+ }
513
+ catch (error) {
514
+ try {
515
+ await this.close(created.id);
516
+ }
517
+ catch (cleanupError) {
518
+ console.error(JSON.stringify({
519
+ level: 'error',
520
+ event: 'retry_session_cleanup_failed',
521
+ sessionId: created.id,
522
+ message: errorMessage(cleanupError)
523
+ }));
524
+ }
525
+ throw error;
526
+ }
527
+ },
528
+ markRetryOf: (newRunId, oldRunId) => {
529
+ const store = this.runStore;
530
+ return store?.setRetryOfRunId?.(newRunId, oldRunId);
531
+ },
532
+ requestError: (status, message) => new SessionRequestError(status, message)
533
+ });
429
534
  }
430
535
  reconfigure(config, workspacePolicy, drivers) {
431
536
  this.config = config;
432
537
  this.workspacePolicy = workspacePolicy;
433
538
  this.drivers = drivers;
434
- this.readinessCache = undefined;
539
+ this.readiness.reconfigure(config, drivers);
435
540
  if (this.cleanupTimer)
436
541
  this.restartCleanup();
437
542
  }
@@ -441,81 +546,25 @@ export class SessionManager {
441
546
  this.restartCleanup();
442
547
  }
443
548
  async listAgents(agentIds, force = false) {
444
- const now = Date.now();
445
- if (!force && this.readinessCache && this.readinessCache.expiresAt > now) {
446
- return filterAgents(this.readinessCache.agents, agentIds);
447
- }
448
- const agents = await Promise.all(Object.entries(this.config.agents).map(async ([id, config]) => {
449
- if (config.protocol === 'a2a')
450
- return probeA2AAgent(id, config);
451
- if (config.enabled === false) {
452
- return {
453
- id,
454
- name: config.name || id,
455
- description: config.description,
456
- protocol: 'acp',
457
- driver: config.driver,
458
- ready: false,
459
- enabled: false,
460
- workspace: this.defaultWorkspace(id),
461
- error: 'Agent is disabled',
462
- checkedAt: Date.now()
463
- };
464
- }
465
- const driver = this.drivers.get(id);
466
- if (!driver) {
467
- return {
468
- id,
469
- name: config.name || id,
470
- description: config.description,
471
- protocol: 'acp',
472
- driver: config.driver,
473
- ready: false,
474
- enabled: true,
475
- workspace: this.defaultWorkspace(id),
476
- error: 'ACP driver is unavailable',
477
- checkedAt: Date.now()
478
- };
479
- }
480
- const startedAt = Date.now();
481
- try {
482
- const probe = await driver.probe();
483
- return {
484
- ...probe,
485
- protocol: 'acp',
486
- driver: config.driver,
487
- enabled: true,
488
- workspace: this.defaultWorkspace(id),
489
- checkedAt: Date.now(),
490
- responseMs: Date.now() - startedAt
491
- };
492
- }
493
- catch (error) {
494
- return {
495
- id,
496
- name: config.name || id,
497
- description: config.description,
498
- protocol: 'acp',
499
- driver: config.driver,
500
- ready: false,
501
- enabled: true,
502
- workspace: this.defaultWorkspace(id),
503
- error: errorMessage(error),
504
- checkedAt: Date.now(),
505
- responseMs: Date.now() - startedAt
506
- };
507
- }
508
- }));
509
- agents.sort((left, right) => left.name.localeCompare(right.name));
510
- this.readinessCache = {
511
- expiresAt: Date.now() + READINESS_CACHE_MS,
512
- agents: structuredClone(agents)
513
- };
514
- return filterAgents(agents, agentIds);
549
+ return this.readiness.listAgents(agentIds, force);
515
550
  }
516
551
  async create(agentId, workspaceInput, ownerKeyId) {
517
- await this.ensureCapacity();
518
- const config = this.config.agents[agentId];
552
+ if (!Object.hasOwn(this.config.agents, agentId) || this.config.agents[agentId].enabled === false) {
553
+ throw new SessionRequestError(404, `Configured agent not found: ${agentId}`);
554
+ }
555
+ let release = () => undefined;
556
+ await this.withSessionLock('admission', async () => {
557
+ release = await this.admission.reserve();
558
+ });
559
+ try {
560
+ return await this.createReserved(agentId, workspaceInput, ownerKeyId, release);
561
+ }
562
+ finally {
563
+ release();
564
+ }
565
+ }
566
+ async createReserved(agentId, workspaceInput, ownerKeyId, release) {
567
+ const config = Object.hasOwn(this.config.agents, agentId) ? this.config.agents[agentId] : undefined;
519
568
  if (!config || config.enabled === false) {
520
569
  throw new SessionRequestError(404, `Configured agent not found: ${agentId}`);
521
570
  }
@@ -532,11 +581,27 @@ export class SessionManager {
532
581
  }
533
582
  workspace = await this.workspacePolicy.resolve(requestedWorkspace);
534
583
  }
535
- 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);
584
+ if (this.admission.isClosing()) {
585
+ throw new SessionRequestError(503, 'Gateway is shutting down');
586
+ }
587
+ 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, this.artifactStore, (runId, state) => this.notifyLifecycle({
588
+ type: 'run_updated',
589
+ sessionId: session.id,
590
+ runId,
591
+ ownerKeyId: session.ownerKeyId,
592
+ state
593
+ }));
536
594
  const runtime = config.protocol === 'a2a'
537
595
  ? new A2AClientRuntime(config, session, this.config.promptTimeoutMs || 30 * 60_000)
538
- : new AcpProcessRuntime(driver, session, this.config.maxOutputChars, this.config.promptTimeoutMs || 30 * 60_000);
596
+ : new AcpProcessRuntime(driver, session, this.config.maxOutputChars, this.config.promptTimeoutMs || 30 * 60_000, this.workspacePolicy.listRoots());
539
597
  this.sessions.set(session.id, session);
598
+ this.notifyLifecycle({
599
+ type: 'session_created',
600
+ sessionId: session.id,
601
+ ownerKeyId: session.ownerKeyId,
602
+ agentId: session.agentId
603
+ });
604
+ release();
540
605
  session.attach(runtime);
541
606
  try {
542
607
  await runtime.start(workspace);
@@ -544,8 +609,22 @@ export class SessionManager {
544
609
  }
545
610
  catch (error) {
546
611
  session.setState('failed', errorMessage(error));
547
- await session.dispose();
548
- this.sessions.delete(session.id);
612
+ try {
613
+ await session.dispose();
614
+ }
615
+ catch (cleanupError) {
616
+ console.error(JSON.stringify({ level: 'error', event: 'session_start_cleanup_failed', sessionId: session.id, message: errorMessage(cleanupError) }));
617
+ }
618
+ finally {
619
+ if (this.sessions.delete(session.id)) {
620
+ this.notifyLifecycle({
621
+ type: 'session_closed',
622
+ sessionId: session.id,
623
+ ownerKeyId: session.ownerKeyId,
624
+ agentId: session.agentId
625
+ });
626
+ }
627
+ }
549
628
  throw new SessionRequestError(502, `Agent failed to start: ${errorMessage(error)}`);
550
629
  }
551
630
  }
@@ -558,8 +637,12 @@ export class SessionManager {
558
637
  count() {
559
638
  return this.sessions.size;
560
639
  }
640
+ subscribeLifecycle(listener) {
641
+ this.lifecycleListeners.add(listener);
642
+ return () => this.lifecycleListeners.delete(listener);
643
+ }
561
644
  listRuns(query = {}) {
562
- return this.runStore?.list(query) || { runs: [], total: 0 };
645
+ return this.runStore?.list(query) || { runs: [], total: 0, stats: { active: 0, completed: 0, failed: 0 } };
563
646
  }
564
647
  getRun(id) {
565
648
  const run = this.runStore?.get(id);
@@ -567,13 +650,51 @@ export class SessionManager {
567
650
  throw new RunNotFoundError(id);
568
651
  return run;
569
652
  }
570
- async message(id, message, attachmentIds = []) {
653
+ getRunOwnerKeyId(id) {
654
+ const store = this.runStore;
655
+ return store?.getOwnerKeyId?.(id);
656
+ }
657
+ ownsRun(id, keyId) {
658
+ return this.getRunOwnerKeyId(id) === keyId;
659
+ }
660
+ getRunArtifacts(id) {
661
+ return this.getRun(id).artifacts;
662
+ }
663
+ getStorageMetrics() {
664
+ return this.runStore?.metrics();
665
+ }
666
+ async readRunArtifact(runId, artifactId) {
667
+ // Read through RunStore so the artifact directory remains the only
668
+ // place that knows how payload filenames are derived and validated.
669
+ this.getRun(runId);
670
+ const store = this.runStore;
671
+ return store?.readArtifact?.(runId, artifactId);
672
+ }
673
+ getRunArtifact(runId, artifactId) {
674
+ return this.readRunArtifact(runId, artifactId);
675
+ }
676
+ getRunControls(id) {
677
+ return this.adminRuns.getRunControls(id);
678
+ }
679
+ async cancelRun(id) {
680
+ return this.adminRuns.cancelRun(id);
681
+ }
682
+ async respondRun(id, response, attachmentIds = []) {
683
+ return this.adminRuns.respondRun(id, response, attachmentIds);
684
+ }
685
+ async retryRun(id, reserveRetry) {
686
+ return this.adminRuns.retryRun(id, reserveRetry);
687
+ }
688
+ retryOfRunId(id) {
689
+ return this.adminRuns.retryOfRunId(id);
690
+ }
691
+ async message(id, message, attachmentIds = [], retryOfRunId) {
571
692
  const text = String(message || '');
572
693
  if (!text.trim())
573
694
  throw new SessionRequestError(400, 'message is required');
574
695
  return this.withSessionLock(id, async () => {
575
696
  const session = this.require(id);
576
- await session.message(text, attachmentIds);
697
+ await session.message(text, attachmentIds, retryOfRunId);
577
698
  return session.snapshot();
578
699
  });
579
700
  }
@@ -609,25 +730,47 @@ export class SessionManager {
609
730
  await session.cancel();
610
731
  const snapshot = session.snapshot();
611
732
  await session.dispose();
612
- this.sessions.delete(id);
733
+ if (this.sessions.delete(id)) {
734
+ this.notifyLifecycle({
735
+ type: 'session_closed',
736
+ sessionId: session.id,
737
+ ownerKeyId: session.ownerKeyId,
738
+ agentId: session.agentId
739
+ });
740
+ }
613
741
  return snapshot;
614
742
  });
615
743
  }
616
744
  eventsAfter(id, after) {
617
745
  return this.require(id).events.after(after);
618
746
  }
747
+ eventReplay(id, after) {
748
+ return this.require(id).events.replay(after);
749
+ }
619
750
  subscribe(id, listener) {
620
751
  return this.require(id).events.subscribe(listener);
621
752
  }
622
753
  async shutdown() {
754
+ this.admission.markClosing();
623
755
  if (this.cleanupTimer)
624
756
  clearInterval(this.cleanupTimer);
625
757
  this.cleanupTimer = undefined;
626
- await Promise.allSettled(Array.from(this.sessions.values()).map(async (session) => {
758
+ const results = await Promise.allSettled(Array.from(this.sessions.values()).map(async (session) => {
627
759
  session.interruptForShutdown();
628
760
  await session.dispose();
629
761
  }));
762
+ for (const session of this.sessions.values()) {
763
+ this.notifyLifecycle({
764
+ type: 'session_closed',
765
+ sessionId: session.id,
766
+ ownerKeyId: session.ownerKeyId,
767
+ agentId: session.agentId
768
+ });
769
+ }
630
770
  this.sessions.clear();
771
+ const failures = results.filter((result) => result.status === 'rejected');
772
+ if (failures.length)
773
+ throw new AggregateError(failures.map((result) => result.reason), 'Failed to dispose one or more Agent sessions');
631
774
  }
632
775
  async withSessionLock(id, operation) {
633
776
  // Serialise mutating operations per session so that concurrent
@@ -657,38 +800,41 @@ export class SessionManager {
657
800
  throw new SessionNotFoundError(id);
658
801
  return session;
659
802
  }
660
- async ensureCapacity() {
661
- const limit = this.config.maxSessions || 64;
662
- if (this.sessions.size < limit)
663
- return;
664
- const terminal = Array.from(this.sessions.values())
665
- .filter((session) => isTerminal(session.state))
666
- .sort((left, right) => left.updatedAt - right.updatedAt);
667
- while (this.sessions.size >= limit && terminal.length) {
668
- const session = terminal.shift();
669
- await session.dispose();
670
- this.sessions.delete(session.id);
671
- }
672
- if (this.sessions.size >= limit) {
673
- throw new SessionRequestError(429, 'Session capacity has been reached');
674
- }
675
- }
676
803
  async cleanup() {
677
804
  const cutoff = Date.now() - this.config.sessionTtlMs;
678
805
  for (const [id, session] of this.sessions) {
679
806
  if (session.updatedAt > cutoff)
680
807
  continue;
681
- if (!isTerminal(session.state)) {
682
- session.setState('failed', 'Session expired after exceeding its lifetime');
683
- }
684
- await session.dispose();
685
- this.sessions.delete(id);
808
+ await this.withSessionLock(id, async () => {
809
+ if (this.sessions.get(id) !== session || session.updatedAt > cutoff)
810
+ return;
811
+ if (!isTerminal(session.state))
812
+ session.setState('failed', 'Session expired after exceeding its lifetime');
813
+ try {
814
+ await session.dispose();
815
+ }
816
+ catch (error) {
817
+ console.error(JSON.stringify({ level: 'error', event: 'session_cleanup_failed', sessionId: id, message: errorMessage(error) }));
818
+ }
819
+ finally {
820
+ if (this.sessions.delete(id)) {
821
+ this.notifyLifecycle({
822
+ type: 'session_closed',
823
+ sessionId: session.id,
824
+ ownerKeyId: session.ownerKeyId,
825
+ agentId: session.agentId
826
+ });
827
+ }
828
+ }
829
+ });
686
830
  }
687
831
  }
688
832
  restartCleanup() {
689
833
  if (this.cleanupTimer)
690
834
  clearInterval(this.cleanupTimer);
691
- this.cleanupTimer = setInterval(() => void this.cleanup(), this.config.cleanupIntervalMs || 60_000);
835
+ this.cleanupTimer = setInterval(() => void this.cleanup().catch((error) => {
836
+ console.error(JSON.stringify({ level: 'error', event: 'session_cleanup_failed', message: errorMessage(error) }));
837
+ }), this.config.cleanupIntervalMs || 60_000);
692
838
  this.cleanupTimer.unref?.();
693
839
  }
694
840
  defaultWorkspace(agentId) {
@@ -697,6 +843,21 @@ export class SessionManager {
697
843
  return '';
698
844
  return config?.workspace || this.config.workspaceRoots[0] || '';
699
845
  }
846
+ notifyLifecycle(event) {
847
+ for (const listener of this.lifecycleListeners) {
848
+ try {
849
+ listener(structuredClone(event));
850
+ }
851
+ catch (error) {
852
+ console.error(JSON.stringify({
853
+ level: 'error',
854
+ event: 'session_lifecycle_listener_failed',
855
+ type: event.type,
856
+ message: errorMessage(error)
857
+ }));
858
+ }
859
+ }
860
+ }
700
861
  }
701
862
  function progressForState(state, error) {
702
863
  const phases = {
@@ -746,10 +907,16 @@ function runArtifact(artifact) {
746
907
  metadata: artifact.metadata ? structuredClone(artifact.metadata) : undefined
747
908
  };
748
909
  }
749
- function assertWorkspacePath(workspaceRoot, target) {
750
- const child = relative(workspaceRoot, target);
751
- if (child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) {
752
- throw new SessionRequestError(403, 'Published files must stay inside the session workspace');
910
+ function publishFailure(error) {
911
+ switch (error.failure) {
912
+ case 'swapped':
913
+ return new SessionRequestError(409, 'Published file changed while it was being opened');
914
+ case 'not-a-file':
915
+ return new SessionRequestError(400, 'Only regular files can be published');
916
+ case 'too-large':
917
+ return new SessionRequestError(413, `Published file exceeds the ${MAX_PUBLISHED_FILE_BYTES} byte limit`);
918
+ case 'outside':
919
+ return new SessionRequestError(403, 'Published files must stay inside the session workspace');
753
920
  }
754
921
  }
755
922
  function mediaTypeForPath(path) {
@@ -822,11 +989,6 @@ export class RunNotFoundError extends Error {
822
989
  this.runId = runId;
823
990
  }
824
991
  }
825
- function filterAgents(agents, ids) {
826
- return agents
827
- .filter((agent) => !ids || ids.has(agent.id))
828
- .map((agent) => structuredClone(agent));
829
- }
830
992
  function isTerminal(state) {
831
993
  return state === 'completed' || state === 'failed' || state === 'canceled';
832
994
  }