libpetri 1.5.0 → 1.7.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.
@@ -1,6 +1,6 @@
1
1
  import { Writable } from 'node:stream';
2
- import { a as PetriNet, b as Transition, T as Token } from '../petri-net-C3Jy5HCt.js';
3
- import { E as EventStore, N as NetEvent } from '../event-store-Y8q_wapJ.js';
2
+ import { a as PetriNet, b as Transition, T as Token } from '../petri-net-DrTpTRNy.js';
3
+ import { E as EventStore, N as NetEvent } from '../event-store-DePCZb33.js';
4
4
 
5
5
  /**
6
6
  * Commands sent from debug UI client to server via WebSocket.
@@ -34,6 +34,8 @@ type DebugCommand = {
34
34
  readonly type: 'listSessions';
35
35
  readonly limit?: number;
36
36
  readonly activeOnly?: boolean;
37
+ /** Optional tag filter (AND semantics). Empty or missing matches all. (libpetri 1.6.0+) */
38
+ readonly tagFilter?: Readonly<Record<string, string>>;
37
39
  } | {
38
40
  readonly type: 'seek';
39
41
  readonly sessionId: string;
@@ -94,6 +96,12 @@ interface SessionSummary {
94
96
  readonly startTime: string;
95
97
  readonly active: boolean;
96
98
  readonly eventCount: number;
99
+ /** User-defined session tags. Empty object if none. (libpetri 1.6.0+) */
100
+ readonly tags?: Readonly<Record<string, string>>;
101
+ /** ISO-8601 end time, present only for completed sessions. (libpetri 1.6.0+) */
102
+ readonly endTime?: string;
103
+ /** Session duration in milliseconds, present only for completed sessions. (libpetri 1.6.0+) */
104
+ readonly durationMs?: number;
97
105
  }
98
106
  interface TokenInfo {
99
107
  readonly id: string | null;
@@ -320,6 +328,15 @@ interface DebugSession {
320
328
  readonly startTime: number;
321
329
  readonly active: boolean;
322
330
  readonly importedStructure: NetStructure | null;
331
+ /** Stamped on first `complete()`. Undefined while the session is active. (libpetri 1.6.0+) */
332
+ readonly endTime?: number;
333
+ /**
334
+ * Per-session tag storage, mutated in place by the registry. The reference is
335
+ * readonly but the underlying object is not — prefer
336
+ * {@link DebugSessionRegistry.tag}/{@link DebugSessionRegistry.tagsFor}
337
+ * over direct access.
338
+ */
339
+ readonly tags: Record<string, string>;
323
340
  }
324
341
  /** Builds the net structure from a session's stored place and transition info. */
325
342
  declare function buildNetStructure(session: DebugSession): NetStructure;
@@ -333,26 +350,54 @@ declare class DebugSessionRegistry {
333
350
  /**
334
351
  * Registers a new debug session for the given Petri net.
335
352
  * Generates DOT diagram and extracts net structure.
353
+ *
354
+ * @param sessionId unique session id
355
+ * @param net the Petri net being executed
356
+ * @param tags optional user-defined tags (libpetri 1.6.0+) — e.g. `{channel: 'voice'}`
336
357
  */
337
- register(sessionId: string, net: PetriNet): DebugSession;
358
+ register(sessionId: string, net: PetriNet, tags?: Readonly<Record<string, string>>): DebugSession;
338
359
  /**
339
360
  * Marks a session as completed (no longer active) and notifies completion listeners.
361
+ *
362
+ * <p>Stamps `endTime = Date.now()` on the first completion. Idempotent: subsequent
363
+ * calls preserve the existing endTime. (libpetri 1.6.0+)
340
364
  */
341
365
  complete(sessionId: string): void;
342
- /** Removes a session from the registry. */
366
+ /** Removes a session from the registry. Tags die with the session. */
343
367
  remove(sessionId: string): DebugSession | undefined;
344
368
  /** Returns a session by ID. */
345
369
  getSession(sessionId: string): DebugSession | undefined;
370
+ /**
371
+ * Sets or overwrites a single tag on a session. (libpetri 1.6.0+)
372
+ *
373
+ * Tags accumulate until the session is removed. Setting a key that already
374
+ * exists replaces its value.
375
+ *
376
+ * If `sessionId` is not a currently-registered session the call is a no-op.
377
+ * A tag write that races with {@link remove} is harmless — the write lands on
378
+ * the now-orphaned session object and is garbage collected along with it.
379
+ */
380
+ tag(sessionId: string, key: string, value: string): void;
381
+ /**
382
+ * Returns a snapshot of the tags attached to a session. Returns an empty
383
+ * object if the session has no tags or does not exist. (libpetri 1.6.0+)
384
+ */
385
+ tagsFor(sessionId: string): Readonly<Record<string, string>>;
346
386
  /** Lists sessions, ordered by start time (most recent first). */
347
- listSessions(limit: number): readonly DebugSession[];
387
+ listSessions(limit: number, tagFilter?: Readonly<Record<string, string>>): readonly DebugSession[];
348
388
  /** Lists only active sessions. */
349
- listActiveSessions(limit: number): readonly DebugSession[];
389
+ listActiveSessions(limit: number, tagFilter?: Readonly<Record<string, string>>): readonly DebugSession[];
350
390
  /** Total number of sessions. */
351
391
  get size(): number;
352
392
  /**
353
393
  * Registers an imported (archived) session as an inactive, read-only session.
394
+ *
395
+ * @param endTime when the original session ended (libpetri 1.6.0+)
396
+ * @param tags user-defined tags attached to the imported session (libpetri 1.6.0+)
354
397
  */
355
- registerImported(sessionId: string, netName: string, dotDiagram: string, structure: NetStructure, eventStore: DebugEventStore, startTime: number): DebugSession;
398
+ registerImported(sessionId: string, netName: string, dotDiagram: string, structure: NetStructure, eventStore: DebugEventStore, startTime: number, endTime?: number, tags?: Readonly<Record<string, string>>): DebugSession;
399
+ /** AND-match: all filter entries must exactly match the session's tags. */
400
+ private matchesTagFilter;
356
401
  /** Notifies all completion listeners. Exceptions are caught and logged. */
357
402
  private notifyCompletionListeners;
358
403
  /** Evicts oldest inactive sessions if at capacity. */
@@ -388,6 +433,7 @@ declare class DebugProtocolHandler {
388
433
  /** Handles a command from a connected client. */
389
434
  handleCommand(clientId: string, command: DebugCommand): void;
390
435
  private handleListSessions;
436
+ private toProtocolSummary;
391
437
  private handleSubscribe;
392
438
  private subscribeLive;
393
439
  private subscribeReplay;
@@ -468,19 +514,101 @@ declare class DebugAwareEventStore implements EventStore {
468
514
 
469
515
  /**
470
516
  * Metadata header for a session archive file.
517
+ *
518
+ * Discriminated union across format versions so callers can pattern-match on
519
+ * `archive.version` to access v2-only fields with type narrowing:
520
+ *
521
+ * ```ts
522
+ * const archive = reader.readMetadata(bytes);
523
+ * if (archive.version === 2) {
524
+ * console.log(archive.tags, archive.endTime, archive.metadata.hasErrors);
525
+ * }
526
+ * ```
527
+ *
528
+ * ## Version contract
529
+ *
530
+ * - **v1** (libpetri 1.5.x–1.6.x): original format. Header carries `sessionId`,
531
+ * `netName`, `dotDiagram`, `startTime`, `eventCount`, and net `structure`.
532
+ * - **v2** (libpetri 1.7.0+): adds `endTime`, user-defined `tags`, and pre-computed
533
+ * {@link SessionMetadata} (event-type histogram, first/last event timestamps,
534
+ * hasErrors). Events inside v2 archives are serialized the same way as in v1 —
535
+ * only the header is enriched.
536
+ *
537
+ * The {@link SessionArchiveReader} peeks the `version` field via a lenient JSON
538
+ * parse and dispatches to the correct concrete type. Both v1 and v2 archives
539
+ * remain readable and may coexist in the same storage bucket.
471
540
  */
472
541
 
473
- interface SessionArchive {
474
- readonly version: number;
542
+ /** Common fields shared by v1 and v2 archive headers. */
543
+ interface SessionArchiveBase {
475
544
  readonly sessionId: string;
476
545
  readonly netName: string;
477
546
  readonly dotDiagram: string;
547
+ /** ISO-8601 instant the session started. */
478
548
  readonly startTime: string;
479
549
  readonly eventCount: number;
480
550
  readonly structure: NetStructure;
481
551
  }
482
- /** Current archive format version. */
483
- declare const CURRENT_VERSION = 1;
552
+ /** Legacy v1 archive header (libpetri 1.5.x–1.6.x). */
553
+ interface SessionArchiveV1 extends SessionArchiveBase {
554
+ readonly version: 1;
555
+ }
556
+ /**
557
+ * v2 archive header (libpetri 1.7.0+). Adds end time, tags, and pre-computed
558
+ * metadata so listing tools and samplers can filter/aggregate without scanning
559
+ * the event body.
560
+ */
561
+ interface SessionArchiveV2 extends SessionArchiveBase {
562
+ readonly version: 2;
563
+ /** ISO-8601 instant the session ended. Undefined for sessions archived while still active. */
564
+ readonly endTime?: string;
565
+ /** User-defined session tags (e.g., `{channel: "voice"}`). Always present; may be empty. */
566
+ readonly tags: Readonly<Record<string, string>>;
567
+ /** Pre-computed aggregate stats. Always present; `emptyMetadata()` for no-event sessions. */
568
+ readonly metadata: SessionMetadata;
569
+ }
570
+ /**
571
+ * Discriminated union of all supported archive header versions.
572
+ *
573
+ * Type-narrowing example:
574
+ * ```ts
575
+ * if (archive.version === 2) {
576
+ * // TS knows archive is SessionArchiveV2 here — tags / endTime / metadata typed.
577
+ * }
578
+ * ```
579
+ */
580
+ type SessionArchive = SessionArchiveV1 | SessionArchiveV2;
581
+ /** Version written by default by {@link SessionArchiveWriter.write} (latest supported). */
582
+ declare const CURRENT_VERSION = 2;
583
+ /**
584
+ * Pre-computed aggregate statistics attached to a v2 session archive header.
585
+ *
586
+ * Computed once during archive write by a single-pass scan of the event store.
587
+ * Readers can answer `hasErrors`, histogram, and first/last timestamp queries
588
+ * without iterating the event stream — enabling cheap triage, sampling, and
589
+ * listing of many archives.
590
+ *
591
+ * For v1 archives (no pre-computed metadata), callers can recompute on-demand
592
+ * via {@link computeMetadata}.
593
+ */
594
+ interface SessionMetadata {
595
+ /**
596
+ * Count of events per `NetEvent` subtype name (PascalCase, matching the
597
+ * wire format used by `NetEventInfo.type` — e.g. `TransitionStarted -> 412`).
598
+ * Keys are stored in alphabetical order for deterministic JSON output.
599
+ */
600
+ readonly eventTypeHistogram: Readonly<Record<string, number>>;
601
+ /** ISO-8601 timestamp of the oldest event, or undefined if the session had no events. */
602
+ readonly firstEventTime?: string;
603
+ /** ISO-8601 timestamp of the newest event, or undefined if the session had no events. */
604
+ readonly lastEventTime?: string;
605
+ /**
606
+ * True if the session contains at least one error-signal event
607
+ * (`TransitionFailed`, `TransitionTimedOut`, `ActionTimedOut`, or
608
+ * a `LogMessage` at level `ERROR`).
609
+ */
610
+ readonly hasErrors: boolean;
611
+ }
484
612
 
485
613
  /**
486
614
  * File-system backed storage for session archives.
@@ -501,21 +629,58 @@ declare class FileSessionArchiveStorage implements SessionArchiveStorage {
501
629
  * Writes a debug session to a length-prefixed binary archive format.
502
630
  *
503
631
  * Format (inside gzip):
504
- * [4 bytes: metadata JSON length][N bytes: metadata JSON]
505
- * [4 bytes: event JSON length][N bytes: event JSON]
632
+ * `[4 bytes: metadata JSON length][N bytes: metadata JSON]`
633
+ * `[4 bytes: event JSON length][N bytes: event JSON]`
506
634
  * ...
507
635
  * (EOF terminates the stream)
636
+ *
637
+ * ## Format selection
638
+ *
639
+ * `write()` defaults to {@link CURRENT_VERSION} (v2 as of libpetri 1.7.0).
640
+ * Callers that need to emit legacy archives — compatibility tests or readers
641
+ * pinned to libpetri ≤ 1.6.1 — can call `writeV1()`. v2 archives cost one extra
642
+ * pass over the event store to pre-compute {@link SessionMetadata}; the savings
643
+ * at read time (no event scan needed for hasErrors / histogram queries) pay it
644
+ * back the first time a caller lists or samples a bucket of sessions.
508
645
  */
509
646
 
510
647
  declare class SessionArchiveWriter {
511
648
  /**
512
- * Writes a complete session archive and returns the compressed bytes.
649
+ * Writes a complete session archive in the current format (v2 as of 1.7.0)
650
+ * and returns the compressed bytes.
513
651
  */
514
652
  write(session: DebugSession): Buffer;
653
+ /**
654
+ * Writes a session in the legacy v1 format. Use only for compatibility
655
+ * testing or when producing archives for consumers pinned to libpetri ≤ 1.6.1.
656
+ */
657
+ writeV1(session: DebugSession): Buffer;
658
+ /**
659
+ * Writes a session in the v2 format — richer header with `endTime`, `tags`,
660
+ * and pre-computed {@link SessionMetadata}.
661
+ *
662
+ * Two passes over the event store: one to compute metadata, one to serialize
663
+ * events. `DebugEventStore` stores events in a plain readonly array and its
664
+ * `[Symbol.iterator]()` returns a fresh array iterator each call, so both
665
+ * passes walk the same sequence from the start.
666
+ */
667
+ writeV2(session: DebugSession): Buffer;
668
+ /**
669
+ * Shared framing logic: length-prefixed header JSON, then length-prefixed
670
+ * event JSON, then gzip. Both v1 and v2 archives use the identical event
671
+ * wire format, so the body loop is version-agnostic.
672
+ */
673
+ private writeFramed;
515
674
  }
516
675
 
517
676
  /**
518
677
  * Reads session archives from length-prefixed binary format.
678
+ *
679
+ * Handles both v1 (libpetri 1.5.x–1.6.x) and v2 (libpetri 1.7.0+) archives via
680
+ * a lenient "version probe": parse the header JSON once, switch on the
681
+ * `version` field, narrow to the correct concrete type, and normalize missing
682
+ * optional fields to their defaults. Events inside the body use the same wire
683
+ * format across versions, so the event read path is shared.
519
684
  */
520
685
 
521
686
  interface ImportedSession {
@@ -118,7 +118,11 @@ var DebugEventStore = class {
118
118
  };
119
119
 
120
120
  // src/debug/archive/session-archive.ts
121
- var CURRENT_VERSION = 1;
121
+ var CURRENT_VERSION = 2;
122
+ var MIN_SUPPORTED_VERSION = 1;
123
+ function emptyMetadata() {
124
+ return { eventTypeHistogram: {}, hasErrors: false };
125
+ }
122
126
 
123
127
  // src/debug/archive/session-archive-reader.ts
124
128
  var MAX_EVENT_SIZE = 10 * 1024 * 1024;
@@ -128,11 +132,7 @@ var SessionArchiveReader = class {
128
132
  const data = gunzipSync(compressed);
129
133
  const metaLen = data.readUInt32BE(0);
130
134
  const metaJson = data.subarray(4, 4 + metaLen).toString("utf-8");
131
- const metadata = JSON.parse(metaJson);
132
- if (metadata.version !== CURRENT_VERSION) {
133
- throw new Error(`Unsupported archive version: ${metadata.version} (expected ${CURRENT_VERSION})`);
134
- }
135
- return metadata;
135
+ return parseHeader(metaJson);
136
136
  }
137
137
  /** Reads the full archive: metadata + all events into a DebugEventStore. */
138
138
  readFull(compressed) {
@@ -142,10 +142,7 @@ var SessionArchiveReader = class {
142
142
  offset += 4;
143
143
  const metaJson = data.subarray(offset, offset + metaLen).toString("utf-8");
144
144
  offset += metaLen;
145
- const metadata = JSON.parse(metaJson);
146
- if (metadata.version !== CURRENT_VERSION) {
147
- throw new Error(`Unsupported archive version: ${metadata.version} (expected ${CURRENT_VERSION})`);
148
- }
145
+ const metadata = parseHeader(metaJson);
149
146
  const eventStore = new DebugEventStore(metadata.sessionId, Number.MAX_SAFE_INTEGER);
150
147
  while (offset < data.length) {
151
148
  if (offset + 4 > data.length) break;
@@ -164,6 +161,25 @@ var SessionArchiveReader = class {
164
161
  return { metadata, eventStore };
165
162
  }
166
163
  };
164
+ function parseHeader(metaJson) {
165
+ const raw = JSON.parse(metaJson);
166
+ switch (raw.version) {
167
+ case 1:
168
+ return raw;
169
+ case 2: {
170
+ const v2 = raw;
171
+ return {
172
+ ...v2,
173
+ tags: v2.tags ?? {},
174
+ metadata: v2.metadata ?? emptyMetadata()
175
+ };
176
+ }
177
+ default:
178
+ throw new Error(
179
+ `Unsupported archive version: ${raw.version} (reader supports ${MIN_SUPPORTED_VERSION}..${CURRENT_VERSION})`
180
+ );
181
+ }
182
+ }
167
183
  function eventInfoToNetEvent(info) {
168
184
  const timestamp = new Date(info.timestamp).getTime();
169
185
  const d = info.details;
@@ -341,8 +357,12 @@ var DebugSessionRegistry = class {
341
357
  /**
342
358
  * Registers a new debug session for the given Petri net.
343
359
  * Generates DOT diagram and extracts net structure.
360
+ *
361
+ * @param sessionId unique session id
362
+ * @param net the Petri net being executed
363
+ * @param tags optional user-defined tags (libpetri 1.6.0+) — e.g. `{channel: 'voice'}`
344
364
  */
345
- register(sessionId, net) {
365
+ register(sessionId, net, tags) {
346
366
  const dotDiagram = dotExport(net);
347
367
  const places = PlaceAnalysis.from(net);
348
368
  const eventStore = this._eventStoreFactory(sessionId);
@@ -355,7 +375,8 @@ var DebugSessionRegistry = class {
355
375
  eventStore,
356
376
  startTime: Date.now(),
357
377
  active: true,
358
- importedStructure: null
378
+ importedStructure: null,
379
+ tags: tags ? { ...tags } : {}
359
380
  };
360
381
  this.evictIfNecessary();
361
382
  this._sessions.set(sessionId, session);
@@ -363,16 +384,20 @@ var DebugSessionRegistry = class {
363
384
  }
364
385
  /**
365
386
  * Marks a session as completed (no longer active) and notifies completion listeners.
387
+ *
388
+ * <p>Stamps `endTime = Date.now()` on the first completion. Idempotent: subsequent
389
+ * calls preserve the existing endTime. (libpetri 1.6.0+)
366
390
  */
367
391
  complete(sessionId) {
368
392
  const session = this._sessions.get(sessionId);
369
393
  if (session) {
370
- const completed = { ...session, active: false };
394
+ const endTime = session.endTime ?? Date.now();
395
+ const completed = { ...session, active: false, endTime };
371
396
  this._sessions.set(sessionId, completed);
372
397
  this.notifyCompletionListeners(completed);
373
398
  }
374
399
  }
375
- /** Removes a session from the registry. */
400
+ /** Removes a session from the registry. Tags die with the session. */
376
401
  remove(sessionId) {
377
402
  const removed = this._sessions.get(sessionId);
378
403
  if (removed) {
@@ -385,13 +410,36 @@ var DebugSessionRegistry = class {
385
410
  getSession(sessionId) {
386
411
  return this._sessions.get(sessionId);
387
412
  }
413
+ /**
414
+ * Sets or overwrites a single tag on a session. (libpetri 1.6.0+)
415
+ *
416
+ * Tags accumulate until the session is removed. Setting a key that already
417
+ * exists replaces its value.
418
+ *
419
+ * If `sessionId` is not a currently-registered session the call is a no-op.
420
+ * A tag write that races with {@link remove} is harmless — the write lands on
421
+ * the now-orphaned session object and is garbage collected along with it.
422
+ */
423
+ tag(sessionId, key, value) {
424
+ const session = this._sessions.get(sessionId);
425
+ if (!session) return;
426
+ session.tags[key] = value;
427
+ }
428
+ /**
429
+ * Returns a snapshot of the tags attached to a session. Returns an empty
430
+ * object if the session has no tags or does not exist. (libpetri 1.6.0+)
431
+ */
432
+ tagsFor(sessionId) {
433
+ const session = this._sessions.get(sessionId);
434
+ return session ? { ...session.tags } : {};
435
+ }
388
436
  /** Lists sessions, ordered by start time (most recent first). */
389
- listSessions(limit) {
390
- return [...this._sessions.values()].sort((a, b) => b.startTime - a.startTime).slice(0, limit);
437
+ listSessions(limit, tagFilter) {
438
+ return [...this._sessions.values()].filter((s) => this.matchesTagFilter(s, tagFilter)).sort((a, b) => b.startTime - a.startTime).slice(0, limit);
391
439
  }
392
440
  /** Lists only active sessions. */
393
- listActiveSessions(limit) {
394
- return [...this._sessions.values()].filter((s) => s.active).sort((a, b) => b.startTime - a.startTime).slice(0, limit);
441
+ listActiveSessions(limit, tagFilter) {
442
+ return [...this._sessions.values()].filter((s) => s.active).filter((s) => this.matchesTagFilter(s, tagFilter)).sort((a, b) => b.startTime - a.startTime).slice(0, limit);
395
443
  }
396
444
  /** Total number of sessions. */
397
445
  get size() {
@@ -399,8 +447,11 @@ var DebugSessionRegistry = class {
399
447
  }
400
448
  /**
401
449
  * Registers an imported (archived) session as an inactive, read-only session.
450
+ *
451
+ * @param endTime when the original session ended (libpetri 1.6.0+)
452
+ * @param tags user-defined tags attached to the imported session (libpetri 1.6.0+)
402
453
  */
403
- registerImported(sessionId, netName, dotDiagram, structure, eventStore, startTime) {
454
+ registerImported(sessionId, netName, dotDiagram, structure, eventStore, startTime, endTime, tags) {
404
455
  this.evictIfNecessary();
405
456
  const session = {
406
457
  sessionId,
@@ -411,11 +462,24 @@ var DebugSessionRegistry = class {
411
462
  eventStore,
412
463
  startTime,
413
464
  active: false,
414
- importedStructure: structure
465
+ importedStructure: structure,
466
+ endTime,
467
+ tags: tags ? { ...tags } : {}
415
468
  };
416
469
  this._sessions.set(sessionId, session);
417
470
  return session;
418
471
  }
472
+ /** AND-match: all filter entries must exactly match the session's tags. */
473
+ matchesTagFilter(session, filter) {
474
+ if (!filter) return true;
475
+ const keys = Object.keys(filter);
476
+ if (keys.length === 0) return true;
477
+ const tags = session.tags;
478
+ for (const key of keys) {
479
+ if (tags[key] !== filter[key]) return false;
480
+ }
481
+ return true;
482
+ }
419
483
  /** Notifies all completion listeners. Exceptions are caught and logged. */
420
484
  notifyCompletionListeners(session) {
421
485
  for (const listener of this._completionListeners) {
@@ -754,15 +818,22 @@ var DebugProtocolHandler = class {
754
818
  // ======================== Command Handlers ========================
755
819
  handleListSessions(client, cmd) {
756
820
  const limit = cmd.limit ?? 50;
757
- const sessions = cmd.activeOnly ? this._sessionRegistry.listActiveSessions(limit) : this._sessionRegistry.listSessions(limit);
758
- const summaries = sessions.map((s) => ({
821
+ const sessions = cmd.activeOnly ? this._sessionRegistry.listActiveSessions(limit, cmd.tagFilter) : this._sessionRegistry.listSessions(limit, cmd.tagFilter);
822
+ const summaries = sessions.map((s) => this.toProtocolSummary(s));
823
+ this.send(client, { type: "sessionList", sessions: summaries });
824
+ }
825
+ toProtocolSummary(s) {
826
+ const tags = this._sessionRegistry.tagsFor(s.sessionId);
827
+ return {
759
828
  sessionId: s.sessionId,
760
829
  netName: s.netName,
761
830
  startTime: new Date(s.startTime).toISOString(),
762
831
  active: s.active,
763
- eventCount: s.eventStore.eventCount()
764
- }));
765
- this.send(client, { type: "sessionList", sessions: summaries });
832
+ eventCount: s.eventStore.eventCount(),
833
+ tags: Object.keys(tags).length > 0 ? tags : void 0,
834
+ endTime: s.endTime !== void 0 ? new Date(s.endTime).toISOString() : void 0,
835
+ durationMs: s.endTime !== void 0 ? s.endTime - s.startTime : void 0
836
+ };
766
837
  }
767
838
  handleSubscribe(client, cmd) {
768
839
  const debugSession = this._sessionRegistry.getSession(cmd.sessionId);
@@ -1024,13 +1095,7 @@ var DebugProtocolHandler = class {
1024
1095
  }
1025
1096
  broadcastSessionList() {
1026
1097
  const sessions = this._sessionRegistry.listSessions(50);
1027
- const summaries = sessions.map((s) => ({
1028
- sessionId: s.sessionId,
1029
- netName: s.netName,
1030
- startTime: new Date(s.startTime).toISOString(),
1031
- active: s.active,
1032
- eventCount: s.eventStore.eventCount()
1033
- }));
1098
+ const summaries = sessions.map((s) => this.toProtocolSummary(s));
1034
1099
  const response = { type: "sessionList", sessions: summaries };
1035
1100
  for (const clientState of this._clients.values()) {
1036
1101
  try {
@@ -1442,23 +1507,105 @@ var FileSessionArchiveStorage = class {
1442
1507
 
1443
1508
  // src/debug/archive/session-archive-writer.ts
1444
1509
  import { gzipSync } from "zlib";
1510
+
1511
+ // src/debug/archive/session-metadata.ts
1512
+ function computeMetadata(events) {
1513
+ const raw = {};
1514
+ let first;
1515
+ let last;
1516
+ let hasErrors = false;
1517
+ for (const event of events) {
1518
+ const typeName = toEventInfo(event).type;
1519
+ raw[typeName] = (raw[typeName] ?? 0) + 1;
1520
+ if (first === void 0) first = event.timestamp;
1521
+ last = event.timestamp;
1522
+ if (isErrorEvent(event)) hasErrors = true;
1523
+ }
1524
+ const histogram = {};
1525
+ for (const key of Object.keys(raw).sort()) {
1526
+ histogram[key] = raw[key];
1527
+ }
1528
+ return {
1529
+ eventTypeHistogram: histogram,
1530
+ firstEventTime: first !== void 0 ? new Date(first).toISOString() : void 0,
1531
+ lastEventTime: last !== void 0 ? new Date(last).toISOString() : void 0,
1532
+ hasErrors
1533
+ };
1534
+ }
1535
+ function isErrorEvent(event) {
1536
+ switch (event.type) {
1537
+ case "transition-failed":
1538
+ case "transition-timed-out":
1539
+ case "action-timed-out":
1540
+ return true;
1541
+ case "log-message":
1542
+ return event.level.toUpperCase() === "ERROR";
1543
+ default:
1544
+ return false;
1545
+ }
1546
+ }
1547
+
1548
+ // src/debug/archive/session-archive-writer.ts
1445
1549
  var SessionArchiveWriter = class {
1446
1550
  /**
1447
- * Writes a complete session archive and returns the compressed bytes.
1551
+ * Writes a complete session archive in the current format (v2 as of 1.7.0)
1552
+ * and returns the compressed bytes.
1448
1553
  */
1449
1554
  write(session) {
1450
- const structure = buildNetStructure(session);
1451
- const metadata = {
1452
- version: CURRENT_VERSION,
1555
+ return this.writeV2(session);
1556
+ }
1557
+ /**
1558
+ * Writes a session in the legacy v1 format. Use only for compatibility
1559
+ * testing or when producing archives for consumers pinned to libpetri ≤ 1.6.1.
1560
+ */
1561
+ writeV1(session) {
1562
+ const header = {
1563
+ version: 1,
1453
1564
  sessionId: session.sessionId,
1454
1565
  netName: session.netName,
1455
1566
  dotDiagram: session.dotDiagram,
1456
1567
  startTime: new Date(session.startTime).toISOString(),
1457
1568
  eventCount: session.eventStore.eventCount(),
1458
- structure
1569
+ structure: buildNetStructure(session)
1459
1570
  };
1571
+ return this.writeFramed(header, session);
1572
+ }
1573
+ /**
1574
+ * Writes a session in the v2 format — richer header with `endTime`, `tags`,
1575
+ * and pre-computed {@link SessionMetadata}.
1576
+ *
1577
+ * Two passes over the event store: one to compute metadata, one to serialize
1578
+ * events. `DebugEventStore` stores events in a plain readonly array and its
1579
+ * `[Symbol.iterator]()` returns a fresh array iterator each call, so both
1580
+ * passes walk the same sequence from the start.
1581
+ */
1582
+ writeV2(session) {
1583
+ const metadata = computeMetadata(session.eventStore);
1584
+ const header = {
1585
+ version: 2,
1586
+ sessionId: session.sessionId,
1587
+ netName: session.netName,
1588
+ dotDiagram: session.dotDiagram,
1589
+ startTime: new Date(session.startTime).toISOString(),
1590
+ endTime: session.endTime !== void 0 ? new Date(session.endTime).toISOString() : void 0,
1591
+ eventCount: session.eventStore.eventCount(),
1592
+ // Snapshot of tags at archive-write time — record the state that was
1593
+ // current when the session was archived, not whatever happens on the
1594
+ // live session afterwards.
1595
+ tags: { ...session.tags },
1596
+ metadata,
1597
+ structure: buildNetStructure(session)
1598
+ };
1599
+ return this.writeFramed(header, session);
1600
+ }
1601
+ /**
1602
+ * Shared framing logic: length-prefixed header JSON, then length-prefixed
1603
+ * event JSON, then gzip. Both v1 and v2 archives use the identical event
1604
+ * wire format, so the body loop is version-agnostic.
1605
+ */
1606
+ writeFramed(header, session) {
1460
1607
  const parts = [];
1461
- const metaBytes = Buffer.from(JSON.stringify(metadata), "utf-8");
1608
+ const metaBytes = Buffer.from(JSON.stringify(header), "utf-8");
1462
1609
  const metaLen = Buffer.alloc(4);
1463
1610
  metaLen.writeUInt32BE(metaBytes.length);
1464
1611
  parts.push(metaLen, metaBytes);
@@ -1469,8 +1616,7 @@ var SessionArchiveWriter = class {
1469
1616
  eventLen.writeUInt32BE(eventBytes.length);
1470
1617
  parts.push(eventLen, eventBytes);
1471
1618
  }
1472
- const raw = Buffer.concat(parts);
1473
- return gzipSync(raw);
1619
+ return gzipSync(Buffer.concat(parts));
1474
1620
  }
1475
1621
  };
1476
1622