@agentuity/runtime 0.1.31 → 0.1.33

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/src/middleware.ts CHANGED
@@ -29,6 +29,7 @@ import * as runtimeConfig from './_config';
29
29
  import { getSessionEventProvider } from './_services';
30
30
  import { internal } from './logger/internal';
31
31
  import { STREAM_DONE_PROMISE_KEY, IS_STREAMING_RESPONSE_KEY } from './handlers/sse';
32
+ import { loadBuildMetadata } from './_metadata';
32
33
 
33
34
  const SESSION_HEADER = 'x-session-id';
34
35
  const THREAD_HEADER = 'x-thread-id';
@@ -302,6 +303,8 @@ export function createOtelMiddleware() {
302
303
  },
303
304
  },
304
305
  async (span): Promise<void> => {
306
+ // Track request duration from the SDK's perspective
307
+ const requestStartTime = performance.now();
305
308
  const sctx = span.spanContext();
306
309
  const sessionId = sctx?.traceId ? `sess_${sctx.traceId}` : generateId('sess');
307
310
 
@@ -321,6 +324,7 @@ export function createOtelMiddleware() {
321
324
 
322
325
  if (projectId) traceState = traceState.set('pid', projectId);
323
326
  if (orgId) traceState = traceState.set('oid', orgId);
327
+ if (deploymentId) traceState = traceState.set('did', deploymentId);
324
328
  if (isDevMode) traceState = traceState.set('d', '1');
325
329
 
326
330
  // Update the active context with the new trace state
@@ -353,8 +357,46 @@ export function createOtelMiddleware() {
353
357
  const sessionEventProvider = getSessionEventProvider();
354
358
  if (sessionEventProvider) {
355
359
  try {
356
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
357
- const routeId = (c as any).var?.routeId || '';
360
+ // Look up routeId from build metadata by matching method and path
361
+ // We need to do this here because the router wrapper hasn't run yet
362
+ const metadata = loadBuildMetadata();
363
+ const methodUpper = c.req.method.toUpperCase();
364
+
365
+ // Normalize paths: trim trailing slashes for consistent matching
366
+ const normalizePath = (p: string) => {
367
+ const decoded = decodeURIComponent(p);
368
+ return decoded.endsWith('/') && decoded.length > 1
369
+ ? decoded.slice(0, -1)
370
+ : decoded;
371
+ };
372
+ const requestPath = normalizePath(c.req.path);
373
+
374
+ // Helper to check if requestPath ends with routePath at a segment boundary
375
+ // e.g., "/api/translate" matches "/translate" but "/api/translate-v2" does not
376
+ const matchesAtSegmentBoundary = (reqPath: string, routePath: string) => {
377
+ if (reqPath === routePath) return true;
378
+ if (!reqPath.endsWith(routePath)) return false;
379
+ // Check that the character before the match is a path separator
380
+ const charBeforeMatch = reqPath[reqPath.length - routePath.length - 1];
381
+ return charBeforeMatch === '/';
382
+ };
383
+
384
+ // Try matching by exact normalized path first
385
+ let route = metadata?.routes?.find(
386
+ (r) =>
387
+ r.method.toUpperCase() === methodUpper &&
388
+ normalizePath(r.path) === requestPath
389
+ );
390
+ // Fall back to segment-boundary matching (handles /api/translate matching /translate)
391
+ if (!route) {
392
+ route = metadata?.routes?.find(
393
+ (r) =>
394
+ r.method.toUpperCase() === methodUpper &&
395
+ matchesAtSegmentBoundary(requestPath, normalizePath(r.path))
396
+ );
397
+ }
398
+ const routeId = route?.id || '';
399
+
358
400
  await sessionEventProvider.start({
359
401
  id: sessionId,
360
402
  threadId: thread.id,
@@ -418,9 +460,35 @@ export function createOtelMiddleware() {
418
460
  }
419
461
  };
420
462
 
463
+ // Track state for finalization
464
+ let responseStatus = 200;
465
+ let errorMessage: string | undefined;
466
+ let handlerDurationMs = 0;
467
+ // Track whether span should be ended in finally block (false for streaming - ended in waitUntil)
468
+ let shouldEndSpanInFinally = true;
469
+
421
470
  try {
471
+ internal.info(
472
+ '[request] %s %s - handler starting (session: %s)',
473
+ method,
474
+ url.pathname,
475
+ sessionId
476
+ );
477
+
422
478
  await next();
423
479
 
480
+ // Capture timing immediately after next() returns - this is when the handler completed
481
+ // This is the HTTP response time we want to report (excludes waitUntil/finalization)
482
+ handlerDurationMs = performance.now() - requestStartTime;
483
+
484
+ internal.info(
485
+ '[request] %s %s - handler completed in %sms (session: %s)',
486
+ method,
487
+ url.pathname,
488
+ handlerDurationMs.toFixed(2),
489
+ sessionId
490
+ );
491
+
424
492
  // Check if this is a streaming response that needs deferred finalization
425
493
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
426
494
  const streamDone = (c as any).get(STREAM_DONE_PROMISE_KEY) as
@@ -433,67 +501,347 @@ export function createOtelMiddleware() {
433
501
  // or if the response status indicates an error
434
502
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
435
503
  const honoError = (c as any).error as Error | undefined;
436
- const responseStatus = c.res?.status ?? 200;
504
+ responseStatus = c.res?.status ?? 200;
437
505
  const isError = honoError || responseStatus >= 500;
438
506
 
507
+ internal.info(
508
+ '[request] %s %s - status: %d, streaming: %s, error: %s (session: %s)',
509
+ method,
510
+ url.pathname,
511
+ responseStatus,
512
+ isStreaming,
513
+ isError,
514
+ sessionId
515
+ );
516
+
517
+ if (isError) {
518
+ // Capture error message for finalization
519
+ errorMessage = honoError
520
+ ? (honoError.stack ?? honoError.message)
521
+ : `HTTP ${responseStatus}`;
522
+ span.setStatus({
523
+ code: SpanStatusCode.ERROR,
524
+ message: honoError?.message ?? errorMessage,
525
+ });
526
+ if (honoError) {
527
+ span.recordException(honoError);
528
+ }
529
+ } else {
530
+ span.setStatus({ code: SpanStatusCode.OK });
531
+ }
532
+
533
+ // For streaming responses, defer everything until stream completes
439
534
  if (isStreaming && streamDone) {
440
- // Defer session/thread saving until stream completes
441
- // This ensures thread state changes made during streaming are persisted
442
535
  internal.info(
443
- '[session] deferring session/thread save until streaming completes (session %s)',
536
+ '[request] %s %s - streaming response, deferring finalization (session: %s)',
537
+ method,
538
+ url.pathname,
444
539
  sessionId
445
540
  );
446
541
 
542
+ // For streaming, we end the span inside waitUntil after setting attributes
543
+ shouldEndSpanInFinally = false;
544
+
545
+ // Capture pending promises BEFORE adding finalization waitUntil to avoid deadlock
546
+ const pendingPromises = handler.getPendingSnapshot();
547
+ const hasPendingTasks = pendingPromises.length > 0;
548
+
549
+ if (hasPendingTasks) {
550
+ internal.info(
551
+ '[request] %s %s - %d pending waitUntil tasks to wait for after stream (session: %s)',
552
+ method,
553
+ url.pathname,
554
+ pendingPromises.length,
555
+ sessionId
556
+ );
557
+ }
558
+
559
+ // Capture values needed for span attributes (responseStatus already captured above)
560
+ const capturedResponseStatus = responseStatus;
561
+ const capturedErrorMessage = errorMessage;
562
+
563
+ // Use waitUntil to handle stream completion and finalization
564
+ // This runs AFTER the response is sent to the client
565
+ // Note: We intentionally do NOT use noSpan here - the waitUntil span helps
566
+ // track the streaming finalization work in telemetry
447
567
  handler.waitUntil(async () => {
568
+ // Track if stream ended with error so we can update finalization status
569
+ let streamError: unknown = undefined;
570
+
448
571
  try {
449
572
  await streamDone;
450
573
  internal.info(
451
- '[session] stream completed, now saving session/thread (session %s)',
574
+ '[request] %s %s - stream completed (session: %s)',
575
+ method,
576
+ url.pathname,
452
577
  sessionId
453
578
  );
454
579
  } catch (ex) {
455
- // Stream ended with an error/abort; still try to persist the latest state
580
+ streamError = ex;
456
581
  internal.info(
457
- '[session] stream ended with error, still saving state: %s',
458
- ex
582
+ '[request] %s %s - stream ended with error: %s (session: %s)',
583
+ method,
584
+ url.pathname,
585
+ ex,
586
+ sessionId
459
587
  );
460
588
  }
461
- await finalizeSession();
462
- });
463
589
 
464
- span.setStatus({ code: SpanStatusCode.OK });
465
- } else if (isError) {
466
- // Hono caught an error or response is 5xx - report as error
467
- const errorMessage = honoError
468
- ? (honoError.stack ?? honoError.message)
469
- : `HTTP ${responseStatus}`;
470
- span.setStatus({
471
- code: SpanStatusCode.ERROR,
472
- message: honoError?.message ?? errorMessage,
590
+ // Record duration now that stream is complete - set attributes BEFORE ending span
591
+ const streamDurationMs = performance.now() - requestStartTime;
592
+ const durationNs = Math.round(streamDurationMs * 1_000_000);
593
+ internal.info(
594
+ '[request] %s %s - recording stream duration: %sms (session: %s)',
595
+ method,
596
+ url.pathname,
597
+ streamDurationMs.toFixed(2),
598
+ sessionId
599
+ );
600
+
601
+ // Determine final status - use stream error if present
602
+ const finalStatus = streamError ? 500 : capturedResponseStatus;
603
+ const finalErrorMessage = streamError
604
+ ? streamError instanceof Error
605
+ ? (streamError.stack ?? streamError.message)
606
+ : String(streamError)
607
+ : capturedErrorMessage;
608
+
609
+ try {
610
+ // Wait for pending tasks (evals, etc.) captured BEFORE this waitUntil was added
611
+ if (hasPendingTasks) {
612
+ internal.info(
613
+ '[request] %s %s - waiting for %d pending waitUntil tasks (session: %s)',
614
+ method,
615
+ url.pathname,
616
+ pendingPromises.length,
617
+ sessionId
618
+ );
619
+ const logger = c.get('logger');
620
+ await handler.waitForPromises(pendingPromises, logger, sessionId);
621
+ internal.info(
622
+ '[request] %s %s - all waitUntil tasks complete (session: %s)',
623
+ method,
624
+ url.pathname,
625
+ sessionId
626
+ );
627
+ }
628
+
629
+ // Finalize session after stream completes and evals finish
630
+ await finalizeSession(
631
+ finalStatus >= 500 ? finalStatus : undefined,
632
+ finalErrorMessage
633
+ );
634
+ internal.info(
635
+ '[request] %s %s - stream session finalization complete (session: %s)',
636
+ method,
637
+ url.pathname,
638
+ sessionId
639
+ );
640
+ } finally {
641
+ // Set span attributes and end span AFTER all work is done
642
+ span.setAttribute('@agentuity/request.duration', durationNs);
643
+ span.setAttribute('http.status_code', finalStatus);
644
+
645
+ // Set span status based on whether there was an error
646
+ if (streamError) {
647
+ span.setStatus({
648
+ code: SpanStatusCode.ERROR,
649
+ message: finalErrorMessage ?? 'Stream ended with error',
650
+ });
651
+ if (streamError instanceof Error) {
652
+ span.recordException(streamError);
653
+ }
654
+ } else {
655
+ span.setStatus({ code: SpanStatusCode.OK });
656
+ }
657
+
658
+ span.end();
659
+ internal.info(
660
+ '[request] %s %s - stream span ended (session: %s)',
661
+ method,
662
+ url.pathname,
663
+ sessionId
664
+ );
665
+ // Note: We don't call waitUntilAll() here because this waitUntil callback
666
+ // IS the final cleanup task. Calling waitUntilAll() would deadlock since
667
+ // it would wait for this very promise to complete.
668
+ }
473
669
  });
474
- if (honoError) {
475
- span.recordException(honoError);
476
- }
477
- await finalizeSession(responseStatus, errorMessage);
478
670
  } else {
479
- // Non-streaming success: save session/thread synchronously
480
- await finalizeSession();
481
- span.setStatus({ code: SpanStatusCode.OK });
671
+ // Non-streaming: record duration immediately
672
+ const durationNs = Math.round(handlerDurationMs * 1_000_000);
673
+ internal.info(
674
+ '[request] %s %s - recording duration: %sms (%dns) (session: %s)',
675
+ method,
676
+ url.pathname,
677
+ handlerDurationMs.toFixed(2),
678
+ durationNs,
679
+ sessionId
680
+ );
681
+ span.setAttribute('@agentuity/request.duration', durationNs);
682
+ span.setAttribute('http.status_code', responseStatus);
683
+
684
+ // Capture pending promises BEFORE adding finalization waitUntil to avoid deadlock.
685
+ // If we called waitUntilAll inside waitUntil, it would wait for itself.
686
+ const pendingPromises = handler.getPendingSnapshot();
687
+ const hasPendingTasks = pendingPromises.length > 0;
688
+
689
+ if (hasPendingTasks) {
690
+ internal.info(
691
+ '[request] %s %s - %d pending waitUntil tasks to wait for (session: %s)',
692
+ method,
693
+ url.pathname,
694
+ pendingPromises.length,
695
+ sessionId
696
+ );
697
+ }
698
+
699
+ // Capture values for use in waitUntil callback
700
+ const capturedResponseStatus = responseStatus;
701
+ const capturedErrorMessage = errorMessage;
702
+
703
+ // Defer session finalization to run AFTER response is sent
704
+ // Use noSpan: true since finalizeSession creates its own Session End span
705
+ handler.waitUntil(
706
+ async () => {
707
+ // Wait for the snapshot of pending tasks (evals, etc.) captured BEFORE this waitUntil was added
708
+ if (hasPendingTasks) {
709
+ internal.info(
710
+ '[request] %s %s - waiting for %d pending waitUntil tasks (session: %s)',
711
+ method,
712
+ url.pathname,
713
+ pendingPromises.length,
714
+ sessionId
715
+ );
716
+ const logger = c.get('logger');
717
+ await handler.waitForPromises(pendingPromises, logger, sessionId);
718
+ internal.info(
719
+ '[request] %s %s - all waitUntil tasks complete (session: %s)',
720
+ method,
721
+ url.pathname,
722
+ sessionId
723
+ );
724
+ }
725
+
726
+ // Finalize session - this is the actual work
727
+ internal.info(
728
+ '[request] %s %s - starting session finalization (session: %s)',
729
+ method,
730
+ url.pathname,
731
+ sessionId
732
+ );
733
+ try {
734
+ await finalizeSession(
735
+ capturedResponseStatus >= 500 ? capturedResponseStatus : undefined,
736
+ capturedErrorMessage
737
+ );
738
+ internal.info(
739
+ '[request] %s %s - session finalization complete (session: %s)',
740
+ method,
741
+ url.pathname,
742
+ sessionId
743
+ );
744
+ } catch (ex) {
745
+ internal.error(
746
+ '[request] %s %s - session finalization failed: %s (session: %s)',
747
+ method,
748
+ url.pathname,
749
+ ex,
750
+ sessionId
751
+ );
752
+ }
753
+ // Note: We don't call waitUntilAll() here because this waitUntil callback
754
+ // IS the final cleanup task. Calling waitUntilAll() would deadlock since
755
+ // it would wait for this very promise to complete.
756
+ },
757
+ { noSpan: true }
758
+ );
482
759
  }
483
760
  } catch (ex) {
761
+ // Record request metrics even on exceptions (500 status)
762
+ const exceptionDurationMs = performance.now() - requestStartTime;
763
+ const durationNs = Math.round(exceptionDurationMs * 1_000_000);
764
+ internal.info(
765
+ '[request] %s %s - recording exception duration: %sms (session: %s)',
766
+ method,
767
+ url.pathname,
768
+ exceptionDurationMs.toFixed(2),
769
+ sessionId
770
+ );
771
+ span.setAttribute('@agentuity/request.duration', durationNs);
772
+ span.setAttribute('http.status_code', 500);
773
+
484
774
  if (ex instanceof Error) {
485
775
  span.recordException(ex);
486
776
  }
487
- const errorMessage = ex instanceof Error ? (ex.stack ?? ex.message) : String(ex);
777
+ errorMessage = ex instanceof Error ? (ex.stack ?? ex.message) : String(ex);
778
+ responseStatus = 500;
488
779
  span.setStatus({
489
780
  code: SpanStatusCode.ERROR,
490
781
  message: ex instanceof Error ? ex.message : String(ex),
491
782
  });
492
783
 
493
- await finalizeSession(500, errorMessage);
784
+ // Capture error message for use in waitUntil callback
785
+ const capturedErrorMessage = errorMessage;
786
+
787
+ // Capture pending promises BEFORE adding finalization waitUntil to avoid deadlock
788
+ const pendingPromises = handler.getPendingSnapshot();
789
+ const hasPendingTasks = pendingPromises.length > 0;
790
+
791
+ if (hasPendingTasks) {
792
+ internal.info(
793
+ '[request] %s %s - %d pending waitUntil tasks to wait for after error (session: %s)',
794
+ method,
795
+ url.pathname,
796
+ pendingPromises.length,
797
+ sessionId
798
+ );
799
+ }
800
+
801
+ // Still defer finalization even on error
802
+ // Use noSpan: true since finalizeSession creates its own Session End span
803
+ handler.waitUntil(
804
+ async () => {
805
+ // Wait for pending tasks (evals, etc.) captured BEFORE this waitUntil was added
806
+ if (hasPendingTasks) {
807
+ internal.info(
808
+ '[request] %s %s - waiting for %d pending waitUntil tasks (session: %s)',
809
+ method,
810
+ url.pathname,
811
+ pendingPromises.length,
812
+ sessionId
813
+ );
814
+ const logger = c.get('logger');
815
+ await handler.waitForPromises(pendingPromises, logger, sessionId);
816
+ internal.info(
817
+ '[request] %s %s - all waitUntil tasks complete (session: %s)',
818
+ method,
819
+ url.pathname,
820
+ sessionId
821
+ );
822
+ }
823
+
824
+ try {
825
+ await finalizeSession(500, capturedErrorMessage);
826
+ } catch (finalizeEx) {
827
+ internal.error(
828
+ '[request] %s %s - error session finalization failed: %s (session: %s)',
829
+ method,
830
+ url.pathname,
831
+ finalizeEx,
832
+ sessionId
833
+ );
834
+ }
835
+ // Note: We don't call waitUntilAll() here because this waitUntil callback
836
+ // IS the final cleanup task. Calling waitUntilAll() would deadlock since
837
+ // it would wait for this very promise to complete.
838
+ },
839
+ { noSpan: true }
840
+ );
494
841
 
495
842
  throw ex;
496
843
  } finally {
844
+ // Set response headers - this is the only thing that should block the response
497
845
  const headers: Record<string, string> = {};
498
846
  propagation.inject(context.active(), headers);
499
847
  for (const key of Object.keys(headers)) {
@@ -501,7 +849,20 @@ export function createOtelMiddleware() {
501
849
  }
502
850
  const traceId = sctx?.traceId || sessionId.replace(/^sess_/, '');
503
851
  c.header(SESSION_HEADER, `sess_${traceId}`);
504
- span.end();
852
+
853
+ internal.info(
854
+ '[request] %s %s - response ready, duration: %sms (session: %s)',
855
+ method,
856
+ url.pathname,
857
+ handlerDurationMs.toFixed(2),
858
+ sessionId
859
+ );
860
+
861
+ // Only end span here for non-streaming responses
862
+ // For streaming, span is ended in the waitUntil callback after setting duration attributes
863
+ if (shouldEndSpanInFinally) {
864
+ span.end();
865
+ }
505
866
  }
506
867
  }
507
868
  );
package/src/router.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { type Context, Hono, type Schema, type Env as HonoEnv } from 'hono';
3
3
  import { returnResponse } from './_util';
4
4
  import type { Env } from './app';
5
+ import { loadBuildMetadata } from './_metadata';
5
6
 
6
7
  // Re-export both Env types
7
8
  export type { Env };
@@ -163,8 +164,34 @@ export const createRouter = <E extends Env = Env, S extends Schema = Schema>():
163
164
  return _originalInvoker(path, ...args);
164
165
  }
165
166
 
166
- // Wrap the handler to add our response conversion
167
+ // Wrap the handler to add our response conversion and set routeId
167
168
  const wrapper = async (c: Context): Promise<Response> => {
169
+ // Look up the route ID from build metadata by matching method and path
170
+ // Try both the registered path and the actual request path (which may include base path)
171
+ const metadata = loadBuildMetadata();
172
+ const methodUpper = method.toUpperCase();
173
+ const requestPath = c.req.routePath || c.req.path;
174
+
175
+ // Try matching by registered path first, then by request path, then by path ending
176
+ let route = metadata?.routes?.find(
177
+ (r) => r.method.toUpperCase() === methodUpper && r.path === path
178
+ );
179
+ if (!route) {
180
+ route = metadata?.routes?.find(
181
+ (r) => r.method.toUpperCase() === methodUpper && r.path === requestPath
182
+ );
183
+ }
184
+ if (!route) {
185
+ // Try matching by path ending (handles /api/translate matching /translate)
186
+ route = metadata?.routes?.find(
187
+ (r) => r.method.toUpperCase() === methodUpper && r.path.endsWith(path)
188
+ );
189
+ }
190
+
191
+ if (route?.id) {
192
+ (c as any).set('routeId', route.id);
193
+ }
194
+
168
195
  let result = handler(c);
169
196
  if (result instanceof Promise) result = await result;
170
197
  // If handler returns a Response, return it unchanged
@@ -8,6 +8,11 @@ import {
8
8
  sandboxRun,
9
9
  sandboxWriteFiles,
10
10
  sandboxReadFile,
11
+ snapshotCreate,
12
+ snapshotGet,
13
+ snapshotList,
14
+ snapshotDelete,
15
+ snapshotTag,
11
16
  } from '@agentuity/server';
12
17
  import type {
13
18
  SandboxService,
@@ -23,6 +28,11 @@ import type {
23
28
  StreamReader,
24
29
  SandboxStatus,
25
30
  FileToWrite,
31
+ SnapshotService,
32
+ SnapshotCreateOptions,
33
+ SnapshotInfo,
34
+ SnapshotListParams,
35
+ SnapshotListResponse,
26
36
  } from '@agentuity/core';
27
37
  import { context, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api';
28
38
 
@@ -149,13 +159,91 @@ function createSandboxInstance(
149
159
  };
150
160
  }
151
161
 
162
+ /**
163
+ * HTTP implementation of the SnapshotService interface
164
+ */
165
+ class HTTPSnapshotService implements SnapshotService {
166
+ private client: APIClient;
167
+
168
+ constructor(client: APIClient) {
169
+ this.client = client;
170
+ }
171
+
172
+ async create(sandboxId: string, options?: SnapshotCreateOptions): Promise<SnapshotInfo> {
173
+ return withSpan(
174
+ 'agentuity.sandbox.snapshot.create',
175
+ {
176
+ 'sandbox.id': sandboxId,
177
+ 'snapshot.name': options?.name ?? '',
178
+ 'snapshot.tag': options?.tag ?? '',
179
+ },
180
+ () =>
181
+ snapshotCreate(this.client, {
182
+ sandboxId,
183
+ name: options?.name,
184
+ description: options?.description,
185
+ tag: options?.tag,
186
+ public: options?.public,
187
+ })
188
+ );
189
+ }
190
+
191
+ async get(snapshotId: string): Promise<SnapshotInfo> {
192
+ return withSpan('agentuity.sandbox.snapshot.get', { 'snapshot.id': snapshotId }, () =>
193
+ snapshotGet(this.client, { snapshotId })
194
+ );
195
+ }
196
+
197
+ async list(params?: SnapshotListParams): Promise<SnapshotListResponse> {
198
+ return withSpan(
199
+ 'agentuity.sandbox.snapshot.list',
200
+ {
201
+ 'snapshot.sandboxId': params?.sandboxId ?? '',
202
+ 'snapshot.limit': params?.limit ?? 50,
203
+ },
204
+ () =>
205
+ snapshotList(this.client, {
206
+ sandboxId: params?.sandboxId,
207
+ limit: params?.limit,
208
+ offset: params?.offset,
209
+ })
210
+ );
211
+ }
212
+
213
+ async delete(snapshotId: string): Promise<void> {
214
+ return withSpan('agentuity.sandbox.snapshot.delete', { 'snapshot.id': snapshotId }, () =>
215
+ snapshotDelete(this.client, { snapshotId })
216
+ );
217
+ }
218
+
219
+ async tag(snapshotId: string, tag: string | null): Promise<SnapshotInfo> {
220
+ return withSpan(
221
+ 'agentuity.sandbox.snapshot.tag',
222
+ {
223
+ 'snapshot.id': snapshotId,
224
+ 'snapshot.tag': tag ?? '',
225
+ },
226
+ () => snapshotTag(this.client, { snapshotId, tag })
227
+ );
228
+ }
229
+ }
230
+
231
+ /**
232
+ * HTTP implementation of the SandboxService interface
233
+ */
152
234
  export class HTTPSandboxService implements SandboxService {
153
235
  private client: APIClient;
154
236
  private streamBaseUrl: string;
155
237
 
238
+ /**
239
+ * Snapshot management operations
240
+ */
241
+ public readonly snapshot: SnapshotService;
242
+
156
243
  constructor(client: APIClient, streamBaseUrl: string) {
157
244
  this.client = client;
158
245
  this.streamBaseUrl = streamBaseUrl;
246
+ this.snapshot = new HTTPSnapshotService(client);
159
247
  }
160
248
 
161
249
  async run(options: SandboxRunOptions): Promise<SandboxRunResult> {