@stacknext/cli 0.6.1 → 0.8.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 (42) hide show
  1. package/dist/collector/server.d.ts +3 -1
  2. package/dist/collector/server.d.ts.map +1 -1
  3. package/dist/collector/server.js +385 -15
  4. package/dist/collector/server.js.map +1 -1
  5. package/dist/commands/dev.d.ts.map +1 -1
  6. package/dist/commands/dev.js +87 -19
  7. package/dist/commands/dev.js.map +1 -1
  8. package/dist/commands/diagnose.d.ts.map +1 -1
  9. package/dist/commands/diagnose.js +36 -3
  10. package/dist/commands/diagnose.js.map +1 -1
  11. package/dist/commands/init.d.ts.map +1 -1
  12. package/dist/commands/init.js +1 -0
  13. package/dist/commands/init.js.map +1 -1
  14. package/dist/commands/skill.d.ts +34 -0
  15. package/dist/commands/skill.d.ts.map +1 -0
  16. package/dist/commands/skill.js +121 -0
  17. package/dist/commands/skill.js.map +1 -0
  18. package/dist/commands/snapshot.d.ts +36 -0
  19. package/dist/commands/snapshot.d.ts.map +1 -0
  20. package/dist/commands/snapshot.js +286 -0
  21. package/dist/commands/snapshot.js.map +1 -0
  22. package/dist/commands/uninstall.d.ts.map +1 -1
  23. package/dist/commands/uninstall.js +18 -5
  24. package/dist/commands/uninstall.js.map +1 -1
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/program.d.ts.map +1 -1
  30. package/dist/program.js +4 -0
  31. package/dist/program.js.map +1 -1
  32. package/dist/runtime.d.ts +17 -0
  33. package/dist/runtime.d.ts.map +1 -1
  34. package/dist/runtime.js +148 -4
  35. package/dist/runtime.js.map +1 -1
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +8 -7
  39. package/skills/stacknext-debugging/SKILL.md +306 -0
  40. package/skills/stacknext-debugging/references/setup.md +228 -0
  41. package/skills/stacknext-debugging/references/tools.md +369 -0
  42. package/skills/stacknext-debugging/references/workflows.md +332 -0
@@ -1,4 +1,4 @@
1
- import { TelemetryStore, type HealthResponse, type NextOtelVerboseStatus, type StackNextConfig } from '@stacknext/core';
1
+ import { TelemetryStore, type HealthResponse, type NextOtelVerboseStatus, type StackNextConfig, type SnapshotSummary } from '@stacknext/core';
2
2
  export interface StartCollectorOptions {
3
3
  port: number;
4
4
  token: string;
@@ -21,6 +21,8 @@ export interface CollectorHandle {
21
21
  token: string;
22
22
  store: TelemetryStore;
23
23
  health: () => HealthResponse;
24
+ /** Saves the current sample before shutdown when snapshots.autoOnExit is on. */
25
+ saveSnapshotOnExit: () => Promise<SnapshotSummary | null>;
24
26
  close: () => Promise<void>;
25
27
  }
26
28
  export declare function resolveDashboardDir(): Promise<string>;
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/collector/server.ts"],"names":[],"mappings":"AAOA,OAAO,EAKL,cAAc,EAkBd,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,eAAe,EAYrB,MAAM,iBAAiB,CAAA;AAoCxB,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,eAAe,CAAA;IACvB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,gFAAgF;IAChF,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,oFAAoF;IACpF,eAAe,CAAC,EAAE,qBAAqB,CAAA;CACxC;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,cAAc,CAAA;IACrB,MAAM,EAAE,MAAM,cAAc,CAAA;IAC5B,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3B;AAoSD,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,MAAM,CAAC,CA2B3D;AAoBD,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CA+gC7F"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/collector/server.ts"],"names":[],"mappings":"AAOA,OAAO,EAKL,cAAc,EAkBd,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,eAAe,EAepB,KAAK,eAAe,EACrB,MAAM,iBAAiB,CAAA;AA8DxB,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE,MAAM,CAAA;IACb,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,eAAe,CAAA;IACvB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/B,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,gFAAgF;IAChF,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,oFAAoF;IACpF,eAAe,CAAC,EAAE,qBAAqB,CAAA;CACxC;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,cAAc,CAAA;IACrB,MAAM,EAAE,MAAM,cAAc,CAAA;IAC5B,gFAAgF;IAChF,kBAAkB,EAAE,MAAM,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAAA;IACzD,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3B;AAySD,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,MAAM,CAAC,CA2B3D;AAoBD,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CAm6C7F"}
@@ -6,6 +6,7 @@ import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { createRequire } from 'node:module';
8
8
  import { DIAGNOSTIC_CATEGORIES, DIAGNOSTIC_RULE_IDS, DIAGNOSTIC_SEVERITIES, DIAGNOSTIC_STATUSES, TelemetryStore, computeCoverage, diagnosticReference, ingestEnvelopeSchema, nextOtelVerboseStatus, } from '@stacknext/core';
9
+ import { SnapshotStorageError, createSnapshotPayload, createSnapshotView, importSnapshot, listSnapshots, loadSnapshotPayload, removeSnapshot, rotateAutomaticSnapshots, saveSnapshot, snapshotExport, snapshotPath, snapshotSummary, isSnapshotId, } from '@stacknext/core/snapshots';
9
10
  import { diagnosticOverview as buildDiagnosticOverview, generateDiagnosticReport, getDiagnosticDetail, listDiagnosticFindings, } from '@stacknext/diagnostics';
10
11
  import { completeRunRequestSchema, createRunRequestSchema, listAllBaselines, listAllComparisons, listAllRuns, loadBaseline, loadComparison, loadRun, RegressionStorageError, submitSampleRequestSchema, } from '@stacknext/regression';
11
12
  import { TestRunRegistry } from './test-runs.js';
@@ -19,6 +20,9 @@ const CONNECTION_RETENTION_MS = 10 * 60 * 1_000;
19
20
  const MAX_SSE_CLIENTS = 50;
20
21
  const SSE_HEARTBEAT_MS = 15_000;
21
22
  const MAX_SESSIONS = 100;
23
+ const AUTO_ERROR_COOLDOWN_MS = 60_000;
24
+ const MAX_SNAPSHOT_BODY_BYTES = 64 * 1024;
25
+ const MAX_SNAPSHOT_CACHE_ENTRIES = 3;
22
26
  function securityHeaders() {
23
27
  return {
24
28
  'x-content-type-options': 'nosniff',
@@ -139,6 +143,9 @@ function filtersFromSearch(searchParams) {
139
143
  ...(queryNumber(searchParams.get('minDuration')) !== undefined
140
144
  ? { minDuration: queryNumber(searchParams.get('minDuration')) }
141
145
  : {}),
146
+ ...(queryBoolean(searchParams.get('httpOnly')) !== undefined
147
+ ? { httpOnly: queryBoolean(searchParams.get('httpOnly')) }
148
+ : {}),
142
149
  ...(sort === 'recent' || sort === 'oldest' || sort === 'slowest' ? { sort } : {}),
143
150
  };
144
151
  }
@@ -254,6 +261,8 @@ function contentTypeFor(file) {
254
261
  return 'text/css; charset=utf-8';
255
262
  case '.json':
256
263
  return 'application/json; charset=utf-8';
264
+ case '.webmanifest':
265
+ return 'application/manifest+json';
257
266
  case '.svg':
258
267
  return 'image/svg+xml';
259
268
  case '.png':
@@ -394,6 +403,12 @@ export async function startCollector(options) {
394
403
  errors: summary.errorCount,
395
404
  databaseQueries: summary.databaseQueryCount,
396
405
  },
406
+ sample: {
407
+ lastReceivedAt: summary.lastReceivedAt,
408
+ firstEventTime: summary.firstEventTime,
409
+ lastEventTime: summary.lastEventTime,
410
+ retentionMs: store.retentionMs,
411
+ },
397
412
  store: storeStats,
398
413
  server: {
399
414
  ...serverStats,
@@ -430,6 +445,7 @@ export async function startCollector(options) {
430
445
  projectName: options.projectName,
431
446
  collectorVersion: options.version ?? VERSION,
432
447
  config: options.config.diagnostics,
448
+ slowRequestMs: options.config.slowRequestMs,
433
449
  generatedAt: now,
434
450
  connected: connectionList.some((connection) => connection.connected),
435
451
  connectedInstances: connectionList.filter((connection) => connection.connected).length,
@@ -451,6 +467,298 @@ export async function startCollector(options) {
451
467
  };
452
468
  };
453
469
  const getDiagnosticReport = () => getDiagnosticState().report;
470
+ const snapshotCache = new Map();
471
+ let autoSaveQueue = Promise.resolve();
472
+ let lastAutoErrorSnapshotAt = 0;
473
+ const projectDir = options.projectDir;
474
+ const snapshotMaxBytes = options.config.snapshots.maxBytes;
475
+ const snapshotHasData = (payload) => payload.spans.length > 0 ||
476
+ payload.errors.length > 0 ||
477
+ payload.databaseQueries.length > 0;
478
+ const saveCurrentSnapshot = async (source, saveOptions = {}) => {
479
+ if (!projectDir) {
480
+ throw new SnapshotStorageError('invalid', 'Snapshot storage needs a project directory. Start StackNext with "stacknext dev" from the app directory.');
481
+ }
482
+ const payload = createSnapshotPayload({
483
+ store,
484
+ source,
485
+ projectName: options.projectName,
486
+ collectorVersion: options.version ?? VERSION,
487
+ config: options.config,
488
+ slowRequestMs: options.config.slowRequestMs,
489
+ ...(saveOptions.label ? { label: saveOptions.label } : {}),
490
+ });
491
+ if (!saveOptions.allowEmpty && !snapshotHasData(payload))
492
+ return null;
493
+ const saved = await saveSnapshot(projectDir, payload, { maxBytes: snapshotMaxBytes });
494
+ if (source === 'exit' || source === 'error') {
495
+ await rotateAutomaticSnapshots(projectDir, options.config.snapshots.autoKeep).catch((error) => {
496
+ log(`could not rotate automatic snapshots: ${error instanceof Error ? error.message : String(error)}`);
497
+ });
498
+ }
499
+ return saved;
500
+ };
501
+ const enqueueAutoErrorSnapshot = () => {
502
+ if (!options.config.snapshots.autoOnError)
503
+ return;
504
+ const now = Date.now();
505
+ if (now - lastAutoErrorSnapshotAt < AUTO_ERROR_COOLDOWN_MS)
506
+ return;
507
+ lastAutoErrorSnapshotAt = now;
508
+ autoSaveQueue = autoSaveQueue
509
+ .then(async () => {
510
+ const saved = await saveCurrentSnapshot('error');
511
+ if (saved)
512
+ log(`automatic error snapshot saved (${saved.id})`);
513
+ })
514
+ .catch((error) => {
515
+ log(`automatic error snapshot failed: ${error instanceof Error ? error.message : String(error)}`);
516
+ });
517
+ };
518
+ const snapshotView = async (id) => {
519
+ if (!projectDir) {
520
+ throw new SnapshotStorageError('not-found', 'Snapshot storage is not available.');
521
+ }
522
+ if (!isSnapshotId(id)) {
523
+ throw new SnapshotStorageError('invalid', `Invalid snapshot id "${id}".`);
524
+ }
525
+ const file = snapshotPath(projectDir, id);
526
+ let info;
527
+ try {
528
+ info = await stat(file);
529
+ }
530
+ catch (error) {
531
+ if (error.code === 'ENOENT') {
532
+ throw new SnapshotStorageError('not-found', 'Snapshot not found.');
533
+ }
534
+ throw error;
535
+ }
536
+ const cached = snapshotCache.get(id);
537
+ if (cached && cached.mtimeMs === info.mtimeMs && cached.ctimeMs === info.ctimeMs) {
538
+ snapshotCache.delete(id);
539
+ snapshotCache.set(id, cached);
540
+ return cached;
541
+ }
542
+ const payload = await loadSnapshotPayload(projectDir, id, { maxBytes: snapshotMaxBytes });
543
+ const entry = {
544
+ ctimeMs: info.ctimeMs,
545
+ mtimeMs: info.mtimeMs,
546
+ bytes: info.size,
547
+ payload,
548
+ store: createSnapshotView(payload),
549
+ };
550
+ snapshotCache.set(id, entry);
551
+ while (snapshotCache.size > MAX_SNAPSHOT_CACHE_ENTRIES) {
552
+ const oldest = snapshotCache.keys().next().value;
553
+ if (oldest === undefined)
554
+ break;
555
+ snapshotCache.delete(oldest);
556
+ }
557
+ return entry;
558
+ };
559
+ const sendSnapshotError = (response, error) => {
560
+ if (error instanceof SnapshotStorageError) {
561
+ if (error.code === 'not-found') {
562
+ sendError(response, 404, error.message);
563
+ }
564
+ else if (error.code === 'too-large') {
565
+ sendError(response, 413, error.message);
566
+ }
567
+ else if (error.code === 'exists') {
568
+ sendError(response, 409, error.message);
569
+ }
570
+ else {
571
+ sendError(response, 422, error.message);
572
+ }
573
+ return;
574
+ }
575
+ log(`snapshot operation failed: ${error instanceof Error ? error.message : String(error)}`);
576
+ sendError(response, 500, 'Snapshot operation failed');
577
+ };
578
+ const handleSnapshotWrite = async (request, response, url) => {
579
+ if (!isReadAuthorized(request)) {
580
+ serverStats.rejectedRequests += 1;
581
+ sendError(response, 401, 'Unauthorized');
582
+ return;
583
+ }
584
+ if (request.method === 'POST' && url.pathname === '/api/snapshots/import') {
585
+ if (!projectDir) {
586
+ sendSnapshotError(response, new SnapshotStorageError('invalid', 'Snapshot storage needs a project directory. Start StackNext with "stacknext dev" from the app directory.'));
587
+ return;
588
+ }
589
+ const contentType = String(request.headers['content-type'] ?? '');
590
+ if (!contentType.toLowerCase().includes('application/json')) {
591
+ sendError(response, 415, 'Content-Type must be application/json');
592
+ return;
593
+ }
594
+ const body = await readBody(request, snapshotMaxBytes);
595
+ if (!body.ok) {
596
+ sendError(response, 413, `Snapshot import exceeds ${snapshotMaxBytes} bytes.`);
597
+ return;
598
+ }
599
+ try {
600
+ const snapshot = await importSnapshot(projectDir, body.body, {
601
+ maxBytes: snapshotMaxBytes,
602
+ });
603
+ sendJson(response, 201, { snapshot });
604
+ }
605
+ catch (error) {
606
+ sendSnapshotError(response, error);
607
+ }
608
+ return;
609
+ }
610
+ if (request.method === 'DELETE') {
611
+ const id = decodeURIComponent(url.pathname.replace(/\/$/, '').slice('/api/snapshots/'.length));
612
+ if (!isSnapshotId(id)) {
613
+ sendError(response, 400, 'Invalid snapshot id');
614
+ return;
615
+ }
616
+ if (!projectDir) {
617
+ sendError(response, 404, 'Snapshot not found');
618
+ return;
619
+ }
620
+ try {
621
+ const removed = await removeSnapshot(projectDir, id);
622
+ snapshotCache.delete(id);
623
+ if (!removed) {
624
+ sendError(response, 404, 'Snapshot not found');
625
+ return;
626
+ }
627
+ sendJson(response, 200, { deleted: true, id });
628
+ }
629
+ catch (error) {
630
+ sendSnapshotError(response, error);
631
+ }
632
+ return;
633
+ }
634
+ const contentType = String(request.headers['content-type'] ?? '');
635
+ if (!contentType.toLowerCase().includes('application/json')) {
636
+ sendError(response, 415, 'Content-Type must be application/json');
637
+ return;
638
+ }
639
+ const body = await readBody(request, MAX_SNAPSHOT_BODY_BYTES);
640
+ if (!body.ok) {
641
+ sendError(response, 413, `Snapshot request exceeds ${MAX_SNAPSHOT_BODY_BYTES} bytes`);
642
+ return;
643
+ }
644
+ let label;
645
+ if (body.body.trim() !== '') {
646
+ let parsed;
647
+ try {
648
+ parsed = JSON.parse(body.body);
649
+ }
650
+ catch {
651
+ sendError(response, 400, 'Malformed JSON payload');
652
+ return;
653
+ }
654
+ if (parsed && typeof parsed === 'object' && 'label' in parsed) {
655
+ const value = parsed.label;
656
+ if (value !== undefined && value !== null) {
657
+ if (typeof value !== 'string' || value.trim().length === 0 || value.length > 200) {
658
+ sendError(response, 422, 'Snapshot label must be 1-200 characters');
659
+ return;
660
+ }
661
+ label = value.trim();
662
+ }
663
+ }
664
+ }
665
+ try {
666
+ const saved = await saveCurrentSnapshot('manual', {
667
+ ...(label ? { label } : {}),
668
+ });
669
+ if (!saved) {
670
+ sendError(response, 409, 'No telemetry in the current sample to save');
671
+ return;
672
+ }
673
+ sendJson(response, 201, { snapshot: saved });
674
+ }
675
+ catch (error) {
676
+ sendSnapshotError(response, error);
677
+ }
678
+ };
679
+ const handleSnapshotApi = async (request, response, url, id, suffix) => {
680
+ try {
681
+ if (suffix === '' || suffix === '/') {
682
+ const entry = await snapshotView(id);
683
+ const detail = {
684
+ snapshot: snapshotSummary(entry.payload, entry.bytes),
685
+ config: entry.payload.config,
686
+ limitations: entry.payload.limitations,
687
+ traces: entry.store.listTraces({ limit: 20 }, entry.payload.config.slowRequestMs).items,
688
+ errors: entry.store.listErrors({ limit: 20 }).items,
689
+ databaseQueries: entry.store.listDatabaseQueries({ limit: 20 }).items,
690
+ };
691
+ sendJson(response, 200, detail);
692
+ return;
693
+ }
694
+ if (suffix === '/export') {
695
+ const entry = await snapshotView(id);
696
+ sendJson(response, 200, snapshotExport(entry.payload), {
697
+ 'content-disposition': `attachment; filename="stacknext-${entry.payload.projectName.replace(/[^a-z0-9_-]+/gi, '-')}-${id}.json"`,
698
+ });
699
+ return;
700
+ }
701
+ if (suffix === '/traces') {
702
+ const entry = await snapshotView(id);
703
+ sendJson(response, 200, entry.store.listTraces(filtersFromSearch(url.searchParams), entry.payload.config.slowRequestMs));
704
+ return;
705
+ }
706
+ if (suffix.startsWith('/traces/')) {
707
+ const traceId = decodeURIComponent(suffix.slice('/traces/'.length));
708
+ if (!/^[0-9a-f]{32}$/i.test(traceId)) {
709
+ sendError(response, 400, 'Invalid trace id');
710
+ return;
711
+ }
712
+ const entry = await snapshotView(id);
713
+ const detail = entry.store.getTrace(traceId, entry.payload.config.slowRequestMs);
714
+ if (!detail) {
715
+ sendError(response, 404, 'Trace not found in snapshot');
716
+ return;
717
+ }
718
+ sendJson(response, 200, detail);
719
+ return;
720
+ }
721
+ if (suffix === '/errors') {
722
+ const entry = await snapshotView(id);
723
+ const result = entry.store.listErrors(errorFiltersFromSearch(url.searchParams));
724
+ const payload = {
725
+ items: result.items,
726
+ total: result.total,
727
+ limit: result.limit,
728
+ offset: result.offset,
729
+ groups: result.groups,
730
+ };
731
+ sendJson(response, 200, payload);
732
+ return;
733
+ }
734
+ if (suffix.startsWith('/errors/')) {
735
+ const errorId = decodeURIComponent(suffix.slice('/errors/'.length));
736
+ if (!errorId || errorId.length > 128) {
737
+ sendError(response, 400, 'Invalid error id');
738
+ return;
739
+ }
740
+ const entry = await snapshotView(id);
741
+ const detail = entry.store.getError(errorId, options.config.slowRequestMs);
742
+ if (!detail) {
743
+ sendError(response, 404, 'Error not found in snapshot');
744
+ return;
745
+ }
746
+ const payload = detail;
747
+ sendJson(response, 200, payload);
748
+ return;
749
+ }
750
+ if (suffix === '/database') {
751
+ const entry = await snapshotView(id);
752
+ const result = entry.store.listDatabaseQueries(databaseFiltersFromSearch(url.searchParams));
753
+ sendJson(response, 200, result);
754
+ return;
755
+ }
756
+ sendError(response, 404, 'Not found');
757
+ }
758
+ catch (error) {
759
+ sendSnapshotError(response, error);
760
+ }
761
+ };
454
762
  const testRuns = new TestRunRegistry({
455
763
  slowRequestMs: options.config.slowRequestMs,
456
764
  isConnected: () => getConnections(Date.now()).some((connection) => connection.connected),
@@ -681,6 +989,8 @@ export async function startCollector(options) {
681
989
  time: now,
682
990
  });
683
991
  }
992
+ if (envelope.errors.length > 0)
993
+ enqueueAutoErrorSnapshot();
684
994
  sendJson(response, 202, { ok: true, accepted, serverTime: now }, { 'x-stacknext-sequence': String(sequence) });
685
995
  };
686
996
  const handleTestRunWrite = async (request, response, url) => {
@@ -1017,20 +1327,6 @@ export async function startCollector(options) {
1017
1327
  sendJson(response, 200, store.listRequests(filtersFromSearch(url.searchParams), options.config.slowRequestMs));
1018
1328
  return;
1019
1329
  }
1020
- if (pathname.startsWith('/api/requests/')) {
1021
- const traceId = pathname.slice('/api/requests/'.length);
1022
- if (!/^[0-9a-f]{32}$/i.test(traceId)) {
1023
- sendError(response, 400, 'Invalid trace id');
1024
- return;
1025
- }
1026
- const detail = store.getRequest(traceId, options.config.slowRequestMs);
1027
- if (!detail) {
1028
- sendError(response, 404, 'Request not found');
1029
- return;
1030
- }
1031
- sendJson(response, 200, traceWithDiagnostics(detail));
1032
- return;
1033
- }
1034
1330
  if (pathname === '/api/traces') {
1035
1331
  const result = store.listTraces(filtersFromSearch(url.searchParams), options.config.slowRequestMs);
1036
1332
  sendJson(response, 200, result);
@@ -1082,6 +1378,45 @@ export async function startCollector(options) {
1082
1378
  sendJson(response, 200, result);
1083
1379
  return;
1084
1380
  }
1381
+ if (pathname === '/api/snapshots') {
1382
+ if (!projectDir) {
1383
+ const empty = {
1384
+ items: [],
1385
+ total: 0,
1386
+ limit: 50,
1387
+ offset: 0,
1388
+ invalid: 0,
1389
+ };
1390
+ sendJson(response, 200, empty);
1391
+ return;
1392
+ }
1393
+ try {
1394
+ const page = await listSnapshots(projectDir, {
1395
+ ...(queryNumber(url.searchParams.get('limit')) !== undefined
1396
+ ? { limit: queryNumber(url.searchParams.get('limit')) }
1397
+ : {}),
1398
+ ...(queryNumber(url.searchParams.get('offset')) !== undefined
1399
+ ? { offset: queryNumber(url.searchParams.get('offset')) }
1400
+ : {}),
1401
+ });
1402
+ const payload = page;
1403
+ sendJson(response, 200, payload);
1404
+ }
1405
+ catch (error) {
1406
+ sendSnapshotError(response, error);
1407
+ }
1408
+ return;
1409
+ }
1410
+ if (pathname.startsWith('/api/snapshots/')) {
1411
+ const match = /^\/api\/snapshots\/([^/]+)(\/.*)?$/.exec(pathname);
1412
+ const id = match?.[1] ? decodeURIComponent(match[1]) : '';
1413
+ if (!isSnapshotId(id)) {
1414
+ sendError(response, 400, 'Invalid snapshot id');
1415
+ return;
1416
+ }
1417
+ await handleSnapshotApi(request, response, url, id, match?.[2] ?? '');
1418
+ return;
1419
+ }
1085
1420
  if (pathname === '/api/export') {
1086
1421
  const data = store.exportAll();
1087
1422
  const payload = {
@@ -1146,12 +1481,31 @@ export async function startCollector(options) {
1146
1481
  if (url.pathname.startsWith('/api/')) {
1147
1482
  const testRunWrite = url.pathname === '/api/test-runs' ||
1148
1483
  url.pathname.startsWith('/api/test-runs/');
1484
+ const snapshotWrite = url.pathname === '/api/snapshots' ||
1485
+ url.pathname.startsWith('/api/snapshots/');
1149
1486
  if (request.method === 'POST' && testRunWrite) {
1150
1487
  await handleTestRunWrite(request, response, url);
1151
1488
  return;
1152
1489
  }
1490
+ if (request.method === 'POST' && url.pathname === '/api/snapshots/import') {
1491
+ await handleSnapshotWrite(request, response, url);
1492
+ return;
1493
+ }
1494
+ if (request.method === 'POST' &&
1495
+ (url.pathname === '/api/snapshots' || url.pathname === '/api/snapshots/')) {
1496
+ await handleSnapshotWrite(request, response, url);
1497
+ return;
1498
+ }
1499
+ if (request.method === 'DELETE' && /^\/api\/snapshots\/[^/]+\/?$/.test(url.pathname)) {
1500
+ await handleSnapshotWrite(request, response, url);
1501
+ return;
1502
+ }
1153
1503
  if (request.method !== 'GET' && request.method !== 'HEAD') {
1154
- response.setHeader('allow', testRunWrite ? 'GET, HEAD, POST' : 'GET, HEAD');
1504
+ response.setHeader('allow', testRunWrite
1505
+ ? 'GET, HEAD, POST'
1506
+ : snapshotWrite
1507
+ ? 'GET, HEAD, POST, DELETE'
1508
+ : 'GET, HEAD');
1155
1509
  sendError(response, 405, 'Method not allowed');
1156
1510
  return;
1157
1511
  }
@@ -1214,6 +1568,21 @@ export async function startCollector(options) {
1214
1568
  }
1215
1569
  port = actualPort;
1216
1570
  dashboardUrl = `http://${host === '127.0.0.1' ? '127.0.0.1' : host}:${port}`;
1571
+ const saveSnapshotOnExit = async () => {
1572
+ if (!options.config.snapshots.autoOnExit)
1573
+ return null;
1574
+ await autoSaveQueue;
1575
+ try {
1576
+ const saved = await saveCurrentSnapshot('exit');
1577
+ if (saved)
1578
+ log(`exit snapshot saved (${saved.id})`);
1579
+ return saved;
1580
+ }
1581
+ catch (error) {
1582
+ log(`exit snapshot failed: ${error instanceof Error ? error.message : String(error)}`);
1583
+ return null;
1584
+ }
1585
+ };
1217
1586
  let closing = false;
1218
1587
  const close = async () => {
1219
1588
  if (closing)
@@ -1252,6 +1621,7 @@ export async function startCollector(options) {
1252
1621
  token: options.token,
1253
1622
  store,
1254
1623
  health: getHealth,
1624
+ saveSnapshotOnExit,
1255
1625
  close,
1256
1626
  };
1257
1627
  }