@fluxpointstudios/orynq-sdk-process-trace 0.2.0 → 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.
@@ -1,725 +1,791 @@
1
- /**
2
- * @fileoverview Main API for building traces - creating runs, adding spans, events, and finalizing.
3
- *
4
- * Location: packages/process-trace/src/trace-builder.ts
5
- *
6
- * This module provides the primary entry points for constructing trace runs. It handles:
7
- * - Creating new trace runs with proper initialization
8
- * - Adding spans (logical groupings of related events)
9
- * - Adding events with automatic sequencing, timestamping, and hashing
10
- * - Closing spans and computing span hashes
11
- * - Finalizing traces with Merkle tree construction and root hash computation
12
- * - Generating public views for external sharing
13
- *
14
- * The trace builder maintains internal state (rolling hash, sequence counters) and
15
- * ensures cryptographic integrity at each step. Events are ordered by monotonic
16
- * sequence numbers (seq), not timestamps, to guarantee deterministic ordering.
17
- *
18
- * Used by:
19
- * - Agent implementations to record execution traces
20
- * - Integration tests for trace verification
21
- * - Audit workflows for compliance reporting
22
- *
23
- * @example
24
- * ```typescript
25
- * // Create a new trace
26
- * const run = await createTrace({ agentId: "agent-1" });
27
- *
28
- * // Add a span for a logical unit of work
29
- * const span = addSpan(run, { name: "build-project" });
30
- *
31
- * // Add events to the span
32
- * await addEvent(run, span.id, { kind: "command", command: "npm install" });
33
- * await addEvent(run, span.id, { kind: "output", stream: "stdout", content: "done" });
34
- *
35
- * // Close the span and finalize
36
- * await closeSpan(run, span.id);
37
- * const bundle = await finalizeTrace(run);
38
- * ```
39
- */
40
-
41
- import type {
42
- TraceRun,
43
- TraceSpan,
44
- TraceEvent,
45
- TraceBundle,
46
- TraceBundlePublicView,
47
- CreateTraceOptions,
48
- CreateSpanOptions,
49
- Visibility,
50
- TraceStatus,
51
- TraceEventKind,
52
- AnnotatedSpan,
53
- } from "./types.js";
54
- import { DEFAULT_EVENT_VISIBILITY } from "./types.js";
55
- import {
56
- computeEventHash,
57
- initRollingHash,
58
- updateRollingHash,
59
- computeRootHash,
60
- } from "./rolling-hash.js";
61
- import { buildSpanMerkleTree, computeSpanHash } from "./merkle.js";
62
-
63
- // =============================================================================
64
- // TRACE CREATION
65
- // =============================================================================
66
-
67
- /**
68
- * Create a new trace run.
69
- *
70
- * Initializes a fresh trace with:
71
- * - Unique UUID for the run ID
72
- * - Schema version "1.0"
73
- * - Status "running"
74
- * - Genesis rolling hash state
75
- * - Empty events and spans arrays
76
- * - Sequence counters at 0
77
- *
78
- * @param opts - Options for creating the trace
79
- * @param opts.agentId - Identifier of the agent producing this trace
80
- * @param opts.description - Optional human-readable description
81
- * @param opts.metadata - Optional key-value metadata
82
- * @returns Promise resolving to the initialized TraceRun
83
- *
84
- * @example
85
- * ```typescript
86
- * const run = await createTrace({
87
- * agentId: "claude-agent-v1",
88
- * description: "Build and test the project",
89
- * metadata: { environment: "production" },
90
- * });
91
- * ```
92
- */
93
- export async function createTrace(opts: CreateTraceOptions): Promise<TraceRun> {
94
- // Validate required fields
95
- if (!opts.agentId || typeof opts.agentId !== "string") {
96
- throw new Error("agentId is required and must be a non-empty string");
97
- }
98
-
99
- // Generate unique run ID using crypto.randomUUID (Node 18+)
100
- const runId = crypto.randomUUID();
101
-
102
- // Initialize rolling hash state
103
- const hashState = await initRollingHash();
104
-
105
- // Build the trace run object
106
- const run: TraceRun = {
107
- id: runId,
108
- schemaVersion: "1.0",
109
- agentId: opts.agentId,
110
- status: "running",
111
- startedAt: new Date().toISOString(),
112
- events: [],
113
- spans: [],
114
- rollingHash: hashState.currentHash,
115
- nextSeq: 0,
116
- nextSpanSeq: 0,
117
- };
118
-
119
- // Add optional metadata
120
- if (opts.metadata !== undefined) {
121
- run.metadata = { ...opts.metadata };
122
- }
123
-
124
- // Add description to metadata if provided
125
- if (opts.description !== undefined) {
126
- run.metadata = {
127
- ...run.metadata,
128
- description: opts.description,
129
- };
130
- }
131
-
132
- return run;
133
- }
134
-
135
- // =============================================================================
136
- // SPAN MANAGEMENT
137
- // =============================================================================
138
-
139
- /**
140
- * Add a new span to a trace run.
141
- *
142
- * Creates a span with:
143
- * - Unique UUID for span ID
144
- * - Assigned spanSeq from run.nextSpanSeq
145
- * - Status "running"
146
- * - Empty eventIds and childSpanIds arrays
147
- *
148
- * If a parentSpanId is provided, the span is added to the parent's childSpanIds.
149
- *
150
- * @param run - The trace run to add the span to (mutated in place)
151
- * @param opts - Options for creating the span
152
- * @param opts.name - Human-readable name for the span
153
- * @param opts.parentSpanId - Optional parent span ID for nesting
154
- * @param opts.visibility - Span visibility level (defaults to "private")
155
- * @param opts.metadata - Optional key-value metadata
156
- * @returns The created TraceSpan
157
- * @throws Error if the run is finalized or parent span is not found
158
- *
159
- * @example
160
- * ```typescript
161
- * // Create a top-level span
162
- * const buildSpan = addSpan(run, { name: "build" });
163
- *
164
- * // Create a nested span
165
- * const installSpan = addSpan(run, {
166
- * name: "npm-install",
167
- * parentSpanId: buildSpan.id,
168
- * visibility: "public",
169
- * });
170
- * ```
171
- */
172
- export function addSpan(run: TraceRun, opts: CreateSpanOptions): TraceSpan {
173
- // Validate run is not finalized
174
- if (isFinalized(run)) {
175
- throw new Error("Cannot add span to a finalized trace run");
176
- }
177
-
178
- // Validate required fields
179
- if (!opts.name || typeof opts.name !== "string") {
180
- throw new Error("name is required and must be a non-empty string");
181
- }
182
-
183
- // Validate parent span exists if specified
184
- if (opts.parentSpanId !== undefined) {
185
- const parentSpan = getSpan(run, opts.parentSpanId);
186
- if (!parentSpan) {
187
- throw new Error(`Parent span not found: ${opts.parentSpanId}`);
188
- }
189
- if (parentSpan.status !== "running") {
190
- throw new Error(`Parent span is not running: ${opts.parentSpanId}`);
191
- }
192
- }
193
-
194
- // Generate unique span ID
195
- const spanId = crypto.randomUUID();
196
-
197
- // Assign spanSeq and increment counter
198
- const spanSeq = run.nextSpanSeq++;
199
-
200
- // Determine visibility (default to "private" if not specified)
201
- const visibility: Visibility = opts.visibility ?? "private";
202
-
203
- // Create the span
204
- const span: TraceSpan = {
205
- id: spanId,
206
- spanSeq,
207
- name: opts.name,
208
- status: "running",
209
- visibility,
210
- startedAt: new Date().toISOString(),
211
- eventIds: [],
212
- childSpanIds: [],
213
- };
214
-
215
- // Add optional fields
216
- if (opts.parentSpanId !== undefined) {
217
- span.parentSpanId = opts.parentSpanId;
218
- }
219
-
220
- if (opts.metadata !== undefined) {
221
- span.metadata = { ...opts.metadata };
222
- }
223
-
224
- // Add to run's spans array
225
- run.spans.push(span);
226
-
227
- // If there's a parent span, add this span to its childSpanIds
228
- if (opts.parentSpanId !== undefined) {
229
- const parentSpan = getSpan(run, opts.parentSpanId);
230
- if (parentSpan) {
231
- parentSpan.childSpanIds.push(spanId);
232
- }
233
- }
234
-
235
- return span;
236
- }
237
-
238
- /**
239
- * Get a span by ID from a run.
240
- *
241
- * @param run - The trace run to search
242
- * @param spanId - The span ID to find
243
- * @returns The span if found, undefined otherwise
244
- *
245
- * @example
246
- * ```typescript
247
- * const span = getSpan(run, "some-span-id");
248
- * if (span) {
249
- * console.log(`Found span: ${span.name}`);
250
- * }
251
- * ```
252
- */
253
- export function getSpan(run: TraceRun, spanId: string): TraceSpan | undefined {
254
- return run.spans.find((s) => s.id === spanId);
255
- }
256
-
257
- /**
258
- * Get events for a span.
259
- *
260
- * Returns all events belonging to the specified span, sorted by sequence number.
261
- *
262
- * @param run - The trace run containing the events
263
- * @param spanId - The span ID to get events for
264
- * @returns Array of TraceEvents for the span, sorted by seq
265
- *
266
- * @example
267
- * ```typescript
268
- * const events = getSpanEvents(run, span.id);
269
- * for (const event of events) {
270
- * console.log(`Event ${event.seq}: ${event.kind}`);
271
- * }
272
- * ```
273
- */
274
- export function getSpanEvents(run: TraceRun, spanId: string): TraceEvent[] {
275
- const span = getSpan(run, spanId);
276
- if (!span) {
277
- return [];
278
- }
279
-
280
- // Get events by their IDs and sort by seq
281
- const eventMap = new Map(run.events.map((e) => [e.id, e]));
282
- const spanEvents = span.eventIds
283
- .map((id) => eventMap.get(id))
284
- .filter((e): e is TraceEvent => e !== undefined);
285
-
286
- return spanEvents.sort((a, b) => a.seq - b.seq);
287
- }
288
-
289
- // =============================================================================
290
- // EVENT MANAGEMENT
291
- // =============================================================================
292
-
293
- /**
294
- * Type helper to extract the event type by kind.
295
- * Used for type-safe event creation without runtime fields.
296
- */
297
- type EventWithoutRuntimeFields<K extends TraceEventKind> = Omit<
298
- Extract<TraceEvent, { kind: K }>,
299
- "id" | "seq" | "timestamp" | "hash"
300
- >;
301
-
302
- /**
303
- * Add an event to a span within a trace run.
304
- *
305
- * Automatically assigns:
306
- * - Unique UUID for event ID
307
- * - Monotonic sequence number from run.nextSeq
308
- * - ISO 8601 timestamp
309
- * - Default visibility based on event kind (if not specified)
310
- * - Computed event hash
311
- *
312
- * Also updates the run's rolling hash to maintain cryptographic chain.
313
- *
314
- * @param run - The trace run (mutated in place)
315
- * @param spanId - ID of the span to add event to
316
- * @param event - Event data without runtime fields (id, seq, timestamp, hash)
317
- * @returns Promise resolving to the complete TraceEvent
318
- * @throws Error if run is finalized, span not found, or span is closed
319
- *
320
- * @example
321
- * ```typescript
322
- * // Add a command event
323
- * const cmdEvent = await addEvent(run, span.id, {
324
- * kind: "command",
325
- * command: "npm install",
326
- * args: ["--save-dev", "typescript"],
327
- * visibility: "public",
328
- * });
329
- *
330
- * // Add an output event (will use default "private" visibility)
331
- * const outEvent = await addEvent(run, span.id, {
332
- * kind: "output",
333
- * stream: "stdout",
334
- * content: "added 120 packages",
335
- * });
336
- * ```
337
- */
338
- export async function addEvent<K extends TraceEventKind>(
339
- run: TraceRun,
340
- spanId: string,
341
- event: EventWithoutRuntimeFields<K>
342
- ): Promise<TraceEvent> {
343
- // Validate run is not finalized
344
- if (isFinalized(run)) {
345
- throw new Error("Cannot add event to a finalized trace run");
346
- }
347
-
348
- // Find the span
349
- const span = getSpan(run, spanId);
350
- if (!span) {
351
- throw new Error(`Span not found: ${spanId}`);
352
- }
353
-
354
- // Validate span is still running
355
- if (span.status !== "running") {
356
- throw new Error(`Cannot add event to closed span: ${spanId} (status: ${span.status})`);
357
- }
358
-
359
- // Validate event has a kind
360
- if (!event.kind || typeof event.kind !== "string") {
361
- throw new Error("Event kind is required and must be a non-empty string");
362
- }
363
-
364
- // Generate event ID
365
- const eventId = crypto.randomUUID();
366
-
367
- // Assign sequence number and increment counter
368
- const seq = run.nextSeq++;
369
-
370
- // Get current timestamp
371
- const timestamp = new Date().toISOString();
372
-
373
- // Determine visibility: use provided value or default for the event kind
374
- const visibility: Visibility =
375
- event.visibility ?? DEFAULT_EVENT_VISIBILITY[event.kind as TraceEventKind] ?? "private";
376
-
377
- // Build the complete event (without hash initially)
378
- // We use 'as unknown as TraceEvent' because TypeScript cannot infer
379
- // that adding runtime fields to EventWithoutRuntimeFields<K> produces a valid TraceEvent.
380
- // The caller ensures the correct event shape via the generic constraint.
381
- const completeEvent = {
382
- ...event,
383
- id: eventId,
384
- seq,
385
- timestamp,
386
- visibility,
387
- } as unknown as TraceEvent;
388
-
389
- // Compute event hash
390
- const eventHash = await computeEventHash(completeEvent);
391
- completeEvent.hash = eventHash;
392
-
393
- // Update rolling hash
394
- const currentState = {
395
- currentHash: run.rollingHash,
396
- itemCount: run.events.length,
397
- };
398
- const newState = await updateRollingHash(currentState, eventHash);
399
- run.rollingHash = newState.currentHash;
400
-
401
- // Add event ID to span's eventIds
402
- span.eventIds.push(eventId);
403
-
404
- // Add event to run's events array
405
- run.events.push(completeEvent);
406
-
407
- return completeEvent;
408
- }
409
-
410
- // =============================================================================
411
- // SPAN CLOSING
412
- // =============================================================================
413
-
414
- /**
415
- * Close a span, marking it as completed/failed/cancelled.
416
- *
417
- * Sets the span's:
418
- * - status (default "completed")
419
- * - endedAt timestamp
420
- * - durationMs (calculated from startedAt to endedAt)
421
- * - hash (computed from span header + event hashes)
422
- *
423
- * @param run - The trace run containing the span (mutated in place)
424
- * @param spanId - ID of the span to close
425
- * @param status - Final status (default "completed")
426
- * @throws Error if span not found or already closed
427
- *
428
- * @example
429
- * ```typescript
430
- * // Close with default "completed" status
431
- * await closeSpan(run, span.id);
432
- *
433
- * // Close with explicit status
434
- * await closeSpan(run, span.id, "failed");
435
- * ```
436
- */
437
- export async function closeSpan(
438
- run: TraceRun,
439
- spanId: string,
440
- status: TraceStatus = "completed"
441
- ): Promise<void> {
442
- // Find the span
443
- const span = getSpan(run, spanId);
444
- if (!span) {
445
- throw new Error(`Span not found: ${spanId}`);
446
- }
447
-
448
- // Validate span is still running
449
- if (span.status !== "running") {
450
- throw new Error(`Span already closed: ${spanId} (status: ${span.status})`);
451
- }
452
-
453
- // Set final status
454
- span.status = status;
455
-
456
- // Set end timestamp
457
- const endedAt = new Date().toISOString();
458
- span.endedAt = endedAt;
459
-
460
- // Calculate duration
461
- const startTime = new Date(span.startedAt).getTime();
462
- const endTime = new Date(endedAt).getTime();
463
- span.durationMs = endTime - startTime;
464
-
465
- // Get event hashes for this span in seq order
466
- const spanEvents = getSpanEvents(run, spanId);
467
- const eventHashes = spanEvents.map((e) => e.hash ?? "");
468
-
469
- // Compute span hash
470
- span.hash = await computeSpanHash(span, eventHashes);
471
- }
472
-
473
- // =============================================================================
474
- // TRACE FINALIZATION
475
- // =============================================================================
476
-
477
- /**
478
- * Check if a run is finalized.
479
- *
480
- * A run is considered finalized when it has a rootHash set.
481
- *
482
- * @param run - The trace run to check
483
- * @returns true if the run is finalized
484
- *
485
- * @example
486
- * ```typescript
487
- * if (!isFinalized(run)) {
488
- * // Can still add spans and events
489
- * await addEvent(run, span.id, { kind: "command", command: "ls" });
490
- * }
491
- * ```
492
- */
493
- export function isFinalized(run: TraceRun): boolean {
494
- return run.rootHash !== undefined;
495
- }
496
-
497
- /**
498
- * Finalize a trace run, computing all final hashes and creating a bundle.
499
- *
500
- * Finalization performs:
501
- * 1. Closes any open spans (with status "completed")
502
- * 2. Sets run status to "completed"
503
- * 3. Sets run endedAt and durationMs
504
- * 4. Builds Merkle tree from spans
505
- * 5. Computes root hash from rolling hash + span hashes
506
- * 6. Creates public view (only public spans with their events)
507
- * 7. Returns complete TraceBundle
508
- *
509
- * After finalization, no more spans or events can be added.
510
- *
511
- * @param run - The trace run to finalize (mutated in place)
512
- * @returns Promise resolving to the complete TraceBundle
513
- * @throws Error if the run is already finalized
514
- *
515
- * @example
516
- * ```typescript
517
- * // Finalize and get the bundle
518
- * const bundle = await finalizeTrace(run);
519
- *
520
- * // Access the cryptographic commitments
521
- * console.log(`Root hash: ${bundle.rootHash}`);
522
- * console.log(`Merkle root: ${bundle.merkleRoot}`);
523
- *
524
- * // Access the public view for sharing
525
- * console.log(`Public spans: ${bundle.publicView.publicSpans.length}`);
526
- * ```
527
- */
528
- export async function finalizeTrace(run: TraceRun): Promise<TraceBundle> {
529
- // Validate run is not already finalized
530
- if (isFinalized(run)) {
531
- throw new Error("Trace run is already finalized");
532
- }
533
-
534
- // Close any open spans
535
- for (const span of run.spans) {
536
- if (span.status === "running") {
537
- await closeSpan(run, span.id, "completed");
538
- }
539
- }
540
-
541
- // Set run status to completed
542
- run.status = "completed";
543
-
544
- // Set end timestamp and duration
545
- const endedAt = new Date().toISOString();
546
- run.endedAt = endedAt;
547
- const startTime = new Date(run.startedAt).getTime();
548
- const endTime = new Date(endedAt).getTime();
549
- run.durationMs = endTime - startTime;
550
-
551
- // Build Merkle tree from spans
552
- const merkleTree = await buildSpanMerkleTree(run.spans, run.events);
553
-
554
- // Compute root hash from rolling hash + span hashes
555
- const rootHash = await computeRootHash(run.rollingHash, run.spans);
556
- run.rootHash = rootHash;
557
-
558
- // Create public view
559
- const publicView = createPublicView(run, merkleTree.rootHash);
560
-
561
- // Build and return the complete bundle
562
- const bundle: TraceBundle = {
563
- formatVersion: "1.0",
564
- publicView,
565
- privateRun: run,
566
- merkleRoot: merkleTree.rootHash,
567
- rootHash,
568
- };
569
-
570
- return bundle;
571
- }
572
-
573
- // =============================================================================
574
- // PUBLIC VIEW GENERATION
575
- // =============================================================================
576
-
577
- /**
578
- * Create a public view of the trace suitable for external sharing.
579
- *
580
- * The public view includes:
581
- * - Run metadata (id, agentId, timestamps, etc.)
582
- * - Cryptographic commitments (rootHash, merkleRoot)
583
- * - Public spans with their events
584
- * - Hashes of redacted (non-public) spans
585
- *
586
- * Private and secret data is excluded, but their hashes are included
587
- * for verification purposes.
588
- *
589
- * @param run - The finalized trace run
590
- * @param merkleRoot - The Merkle root from the span tree
591
- * @returns The public view of the trace bundle
592
- */
593
- function createPublicView(
594
- run: TraceRun,
595
- merkleRoot: string
596
- ): TraceBundlePublicView {
597
- // Build event lookup map
598
- const eventMap = new Map(run.events.map((e) => [e.id, e]));
599
-
600
- // Separate public spans from non-public
601
- const publicSpans: AnnotatedSpan[] = [];
602
- const redactedSpanHashes: Array<{ spanId: string; hash: string }> = [];
603
-
604
- for (const span of run.spans) {
605
- if (span.visibility === "public") {
606
- // Include public spans with their events
607
- const spanEvents = span.eventIds
608
- .map((id) => eventMap.get(id))
609
- .filter((e): e is TraceEvent => e !== undefined)
610
- // Only include public events within public spans
611
- .filter((e) => e.visibility === "public")
612
- .sort((a, b) => a.seq - b.seq);
613
-
614
- const annotatedSpan: AnnotatedSpan = {
615
- ...span,
616
- events: spanEvents,
617
- };
618
- publicSpans.push(annotatedSpan);
619
- } else {
620
- // Include only the hash for non-public spans
621
- if (span.hash) {
622
- redactedSpanHashes.push({
623
- spanId: span.id,
624
- hash: span.hash,
625
- });
626
- }
627
- }
628
- }
629
-
630
- // Sort public spans by spanSeq
631
- publicSpans.sort((a, b) => a.spanSeq - b.spanSeq);
632
-
633
- // Sort redacted span hashes by spanId for consistency
634
- redactedSpanHashes.sort((a, b) => a.spanId.localeCompare(b.spanId));
635
-
636
- const publicView: TraceBundlePublicView = {
637
- runId: run.id,
638
- agentId: run.agentId,
639
- schemaVersion: run.schemaVersion,
640
- startedAt: run.startedAt,
641
- endedAt: run.endedAt ?? run.startedAt, // Fallback for safety
642
- durationMs: run.durationMs ?? 0,
643
- status: run.status,
644
- totalEvents: run.events.length,
645
- totalSpans: run.spans.length,
646
- rootHash: run.rootHash ?? "",
647
- merkleRoot,
648
- publicSpans,
649
- redactedSpanHashes,
650
- };
651
-
652
- return publicView;
653
- }
654
-
655
- // =============================================================================
656
- // UTILITY FUNCTIONS
657
- // =============================================================================
658
-
659
- /**
660
- * Get total event count for a trace run.
661
- *
662
- * @param run - The trace run
663
- * @returns Number of events in the run
664
- */
665
- export function getEventCount(run: TraceRun): number {
666
- return run.events.length;
667
- }
668
-
669
- /**
670
- * Get total span count for a trace run.
671
- *
672
- * @param run - The trace run
673
- * @returns Number of spans in the run
674
- */
675
- export function getSpanCount(run: TraceRun): number {
676
- return run.spans.length;
677
- }
678
-
679
- /**
680
- * Get all root spans (spans without a parent).
681
- *
682
- * @param run - The trace run
683
- * @returns Array of root-level spans
684
- */
685
- export function getRootSpans(run: TraceRun): TraceSpan[] {
686
- return run.spans.filter((s) => s.parentSpanId === undefined);
687
- }
688
-
689
- /**
690
- * Get child spans for a given parent span.
691
- *
692
- * @param run - The trace run
693
- * @param parentSpanId - The parent span ID
694
- * @returns Array of child spans
695
- */
696
- export function getChildSpans(run: TraceRun, parentSpanId: string): TraceSpan[] {
697
- return run.spans.filter((s) => s.parentSpanId === parentSpanId);
698
- }
699
-
700
- /**
701
- * Get an event by ID from a run.
702
- *
703
- * @param run - The trace run
704
- * @param eventId - The event ID to find
705
- * @returns The event if found, undefined otherwise
706
- */
707
- export function getEvent(run: TraceRun, eventId: string): TraceEvent | undefined {
708
- return run.events.find((e) => e.id === eventId);
709
- }
710
-
711
- /**
712
- * Get all events of a specific kind from a run.
713
- *
714
- * @param run - The trace run
715
- * @param kind - The event kind to filter by
716
- * @returns Array of events matching the kind
717
- */
718
- export function getEventsByKind<K extends TraceEventKind>(
719
- run: TraceRun,
720
- kind: K
721
- ): Extract<TraceEvent, { kind: K }>[] {
722
- return run.events.filter(
723
- (e): e is Extract<TraceEvent, { kind: K }> => e.kind === kind
724
- );
725
- }
1
+ /**
2
+ * @fileoverview Main API for building traces - creating runs, adding spans, events, and finalizing.
3
+ *
4
+ * Location: packages/process-trace/src/trace-builder.ts
5
+ *
6
+ * This module provides the primary entry points for constructing trace runs. It handles:
7
+ * - Creating new trace runs with proper initialization
8
+ * - Adding spans (logical groupings of related events)
9
+ * - Adding events with automatic sequencing, timestamping, and hashing
10
+ * - Closing spans and computing span hashes
11
+ * - Finalizing traces with Merkle tree construction and root hash computation
12
+ * - Generating public views for external sharing
13
+ *
14
+ * The trace builder maintains internal state (rolling hash, sequence counters) and
15
+ * ensures cryptographic integrity at each step. Events are ordered by monotonic
16
+ * sequence numbers (seq), not timestamps, to guarantee deterministic ordering.
17
+ *
18
+ * Used by:
19
+ * - Agent implementations to record execution traces
20
+ * - Integration tests for trace verification
21
+ * - Audit workflows for compliance reporting
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * // Create a new trace
26
+ * const run = await createTrace({ agentId: "agent-1" });
27
+ *
28
+ * // Add a span for a logical unit of work
29
+ * const span = addSpan(run, { name: "build-project" });
30
+ *
31
+ * // Add events to the span
32
+ * await addEvent(run, span.id, { kind: "command", command: "npm install" });
33
+ * await addEvent(run, span.id, { kind: "output", stream: "stdout", content: "done" });
34
+ *
35
+ * // Close the span and finalize
36
+ * await closeSpan(run, span.id);
37
+ * const bundle = await finalizeTrace(run);
38
+ * ```
39
+ */
40
+
41
+ import type {
42
+ TraceRun,
43
+ TraceSpan,
44
+ TraceEvent,
45
+ TraceBundle,
46
+ TraceBundlePublicView,
47
+ CreateTraceOptions,
48
+ CreateSpanOptions,
49
+ Visibility,
50
+ TraceStatus,
51
+ TraceEventKind,
52
+ AnnotatedSpan,
53
+ } from "./types.js";
54
+ import { DEFAULT_EVENT_VISIBILITY } from "./types.js";
55
+ import {
56
+ computeEventHash,
57
+ initRollingHash,
58
+ updateRollingHash,
59
+ computeRootHash,
60
+ } from "./rolling-hash.js";
61
+ import { buildSpanMerkleTree, computeSpanHash } from "./merkle.js";
62
+ import {
63
+ computeModelManifestHash,
64
+ validateModelManifest,
65
+ freezeModelManifest,
66
+ } from "./model-manifest.js";
67
+
68
+ // =============================================================================
69
+ // TRACE CREATION
70
+ // =============================================================================
71
+
72
+ /**
73
+ * Create a new trace run.
74
+ *
75
+ * Initializes a fresh trace with:
76
+ * - Unique UUID for the run ID
77
+ * - Schema version "1.0"
78
+ * - Status "running"
79
+ * - Genesis rolling hash state
80
+ * - Empty events and spans arrays
81
+ * - Sequence counters at 0
82
+ *
83
+ * @param opts - Options for creating the trace
84
+ * @param opts.agentId - Identifier of the agent producing this trace
85
+ * @param opts.description - Optional human-readable description
86
+ * @param opts.metadata - Optional key-value metadata
87
+ * @returns Promise resolving to the initialized TraceRun
88
+ *
89
+ * @example
90
+ * ```typescript
91
+ * const run = await createTrace({
92
+ * agentId: "claude-agent-v1",
93
+ * description: "Build and test the project",
94
+ * metadata: { environment: "production" },
95
+ * });
96
+ * ```
97
+ */
98
+ export async function createTrace(opts: CreateTraceOptions): Promise<TraceRun> {
99
+ // Validate required fields
100
+ if (!opts.agentId || typeof opts.agentId !== "string") {
101
+ throw new Error("agentId is required and must be a non-empty string");
102
+ }
103
+
104
+ // Generate unique run ID using crypto.randomUUID (Node 18+)
105
+ const runId = crypto.randomUUID();
106
+
107
+ // Initialize rolling hash state
108
+ const hashState = await initRollingHash();
109
+
110
+ // Build the trace run object
111
+ const run: TraceRun = {
112
+ id: runId,
113
+ schemaVersion: "1.0",
114
+ agentId: opts.agentId,
115
+ status: "running",
116
+ startedAt: new Date().toISOString(),
117
+ events: [],
118
+ spans: [],
119
+ rollingHash: hashState.currentHash,
120
+ nextSeq: 0,
121
+ nextSpanSeq: 0,
122
+ };
123
+
124
+ // Add optional metadata
125
+ if (opts.metadata !== undefined) {
126
+ run.metadata = { ...opts.metadata };
127
+ }
128
+
129
+ // Add description to metadata if provided
130
+ if (opts.description !== undefined) {
131
+ run.metadata = {
132
+ ...run.metadata,
133
+ description: opts.description,
134
+ };
135
+ }
136
+
137
+ // -------------------------------------------------------------------------
138
+ // Pre-execution model-manifest pinning (issue #59)
139
+ // -------------------------------------------------------------------------
140
+ const strict = opts.strict ?? false;
141
+ if (strict) {
142
+ run.strict = true;
143
+ }
144
+
145
+ if (opts.manifest !== undefined) {
146
+ const manifest = validateModelManifest(opts.manifest);
147
+ // Pin the hash now, BEFORE any event is recorded — this is the enforcement
148
+ // point for "the model was not altered over the run".
149
+ run.modelManifestHash = await computeModelManifestHash(manifest);
150
+ // Freeze the object so any later mutation throws (ESM strict mode). The pin
151
+ // is the hash computed above, not the live object.
152
+ run.modelManifest = freezeModelManifest(manifest);
153
+ } else if (strict) {
154
+ throw new Error(
155
+ "createTrace: strict mode requires a `manifest` to be pinned before execution"
156
+ );
157
+ }
158
+
159
+ return run;
160
+ }
161
+
162
+ // =============================================================================
163
+ // SPAN MANAGEMENT
164
+ // =============================================================================
165
+
166
+ /**
167
+ * Add a new span to a trace run.
168
+ *
169
+ * Creates a span with:
170
+ * - Unique UUID for span ID
171
+ * - Assigned spanSeq from run.nextSpanSeq
172
+ * - Status "running"
173
+ * - Empty eventIds and childSpanIds arrays
174
+ *
175
+ * If a parentSpanId is provided, the span is added to the parent's childSpanIds.
176
+ *
177
+ * @param run - The trace run to add the span to (mutated in place)
178
+ * @param opts - Options for creating the span
179
+ * @param opts.name - Human-readable name for the span
180
+ * @param opts.parentSpanId - Optional parent span ID for nesting
181
+ * @param opts.visibility - Span visibility level (defaults to "private")
182
+ * @param opts.metadata - Optional key-value metadata
183
+ * @returns The created TraceSpan
184
+ * @throws Error if the run is finalized or parent span is not found
185
+ *
186
+ * @example
187
+ * ```typescript
188
+ * // Create a top-level span
189
+ * const buildSpan = addSpan(run, { name: "build" });
190
+ *
191
+ * // Create a nested span
192
+ * const installSpan = addSpan(run, {
193
+ * name: "npm-install",
194
+ * parentSpanId: buildSpan.id,
195
+ * visibility: "public",
196
+ * });
197
+ * ```
198
+ */
199
+ export function addSpan(run: TraceRun, opts: CreateSpanOptions): TraceSpan {
200
+ // Validate run is not finalized
201
+ if (isFinalized(run)) {
202
+ throw new Error("Cannot add span to a finalized trace run");
203
+ }
204
+
205
+ // Validate required fields
206
+ if (!opts.name || typeof opts.name !== "string") {
207
+ throw new Error("name is required and must be a non-empty string");
208
+ }
209
+
210
+ // Validate parent span exists if specified
211
+ if (opts.parentSpanId !== undefined) {
212
+ const parentSpan = getSpan(run, opts.parentSpanId);
213
+ if (!parentSpan) {
214
+ throw new Error(`Parent span not found: ${opts.parentSpanId}`);
215
+ }
216
+ if (parentSpan.status !== "running") {
217
+ throw new Error(`Parent span is not running: ${opts.parentSpanId}`);
218
+ }
219
+ }
220
+
221
+ // Generate unique span ID
222
+ const spanId = crypto.randomUUID();
223
+
224
+ // Assign spanSeq and increment counter
225
+ const spanSeq = run.nextSpanSeq++;
226
+
227
+ // Determine visibility (default to "private" if not specified)
228
+ const visibility: Visibility = opts.visibility ?? "private";
229
+
230
+ // Create the span
231
+ const span: TraceSpan = {
232
+ id: spanId,
233
+ spanSeq,
234
+ name: opts.name,
235
+ status: "running",
236
+ visibility,
237
+ startedAt: new Date().toISOString(),
238
+ eventIds: [],
239
+ childSpanIds: [],
240
+ };
241
+
242
+ // Add optional fields
243
+ if (opts.parentSpanId !== undefined) {
244
+ span.parentSpanId = opts.parentSpanId;
245
+ }
246
+
247
+ if (opts.metadata !== undefined) {
248
+ span.metadata = { ...opts.metadata };
249
+ }
250
+
251
+ // Add to run's spans array
252
+ run.spans.push(span);
253
+
254
+ // If there's a parent span, add this span to its childSpanIds
255
+ if (opts.parentSpanId !== undefined) {
256
+ const parentSpan = getSpan(run, opts.parentSpanId);
257
+ if (parentSpan) {
258
+ parentSpan.childSpanIds.push(spanId);
259
+ }
260
+ }
261
+
262
+ return span;
263
+ }
264
+
265
+ /**
266
+ * Get a span by ID from a run.
267
+ *
268
+ * @param run - The trace run to search
269
+ * @param spanId - The span ID to find
270
+ * @returns The span if found, undefined otherwise
271
+ *
272
+ * @example
273
+ * ```typescript
274
+ * const span = getSpan(run, "some-span-id");
275
+ * if (span) {
276
+ * console.log(`Found span: ${span.name}`);
277
+ * }
278
+ * ```
279
+ */
280
+ export function getSpan(run: TraceRun, spanId: string): TraceSpan | undefined {
281
+ return run.spans.find((s) => s.id === spanId);
282
+ }
283
+
284
+ /**
285
+ * Get events for a span.
286
+ *
287
+ * Returns all events belonging to the specified span, sorted by sequence number.
288
+ *
289
+ * @param run - The trace run containing the events
290
+ * @param spanId - The span ID to get events for
291
+ * @returns Array of TraceEvents for the span, sorted by seq
292
+ *
293
+ * @example
294
+ * ```typescript
295
+ * const events = getSpanEvents(run, span.id);
296
+ * for (const event of events) {
297
+ * console.log(`Event ${event.seq}: ${event.kind}`);
298
+ * }
299
+ * ```
300
+ */
301
+ export function getSpanEvents(run: TraceRun, spanId: string): TraceEvent[] {
302
+ const span = getSpan(run, spanId);
303
+ if (!span) {
304
+ return [];
305
+ }
306
+
307
+ // Get events by their IDs and sort by seq
308
+ const eventMap = new Map(run.events.map((e) => [e.id, e]));
309
+ const spanEvents = span.eventIds
310
+ .map((id) => eventMap.get(id))
311
+ .filter((e): e is TraceEvent => e !== undefined);
312
+
313
+ return spanEvents.sort((a, b) => a.seq - b.seq);
314
+ }
315
+
316
+ // =============================================================================
317
+ // EVENT MANAGEMENT
318
+ // =============================================================================
319
+
320
+ /**
321
+ * Type helper to extract the event type by kind.
322
+ * Used for type-safe event creation without runtime fields.
323
+ */
324
+ type EventWithoutRuntimeFields<K extends TraceEventKind> = Omit<
325
+ Extract<TraceEvent, { kind: K }>,
326
+ "id" | "seq" | "timestamp" | "hash"
327
+ >;
328
+
329
+ /**
330
+ * Add an event to a span within a trace run.
331
+ *
332
+ * Automatically assigns:
333
+ * - Unique UUID for event ID
334
+ * - Monotonic sequence number from run.nextSeq
335
+ * - ISO 8601 timestamp
336
+ * - Default visibility based on event kind (if not specified)
337
+ * - Computed event hash
338
+ *
339
+ * Also updates the run's rolling hash to maintain cryptographic chain.
340
+ *
341
+ * @param run - The trace run (mutated in place)
342
+ * @param spanId - ID of the span to add event to
343
+ * @param event - Event data without runtime fields (id, seq, timestamp, hash)
344
+ * @returns Promise resolving to the complete TraceEvent
345
+ * @throws Error if run is finalized, span not found, or span is closed
346
+ *
347
+ * @example
348
+ * ```typescript
349
+ * // Add a command event
350
+ * const cmdEvent = await addEvent(run, span.id, {
351
+ * kind: "command",
352
+ * command: "npm install",
353
+ * args: ["--save-dev", "typescript"],
354
+ * visibility: "public",
355
+ * });
356
+ *
357
+ * // Add an output event (will use default "private" visibility)
358
+ * const outEvent = await addEvent(run, span.id, {
359
+ * kind: "output",
360
+ * stream: "stdout",
361
+ * content: "added 120 packages",
362
+ * });
363
+ * ```
364
+ */
365
+ export async function addEvent<K extends TraceEventKind>(
366
+ run: TraceRun,
367
+ spanId: string,
368
+ event: EventWithoutRuntimeFields<K>
369
+ ): Promise<TraceEvent> {
370
+ // Validate run is not finalized
371
+ if (isFinalized(run)) {
372
+ throw new Error("Cannot add event to a finalized trace run");
373
+ }
374
+
375
+ // Find the span
376
+ const span = getSpan(run, spanId);
377
+ if (!span) {
378
+ throw new Error(`Span not found: ${spanId}`);
379
+ }
380
+
381
+ // Validate span is still running
382
+ if (span.status !== "running") {
383
+ throw new Error(`Cannot add event to closed span: ${spanId} (status: ${span.status})`);
384
+ }
385
+
386
+ // Validate event has a kind
387
+ if (!event.kind || typeof event.kind !== "string") {
388
+ throw new Error("Event kind is required and must be a non-empty string");
389
+ }
390
+
391
+ // Generate event ID
392
+ const eventId = crypto.randomUUID();
393
+
394
+ // Assign sequence number and increment counter
395
+ const seq = run.nextSeq++;
396
+
397
+ // Get current timestamp
398
+ const timestamp = new Date().toISOString();
399
+
400
+ // Determine visibility: use provided value or default for the event kind
401
+ const visibility: Visibility =
402
+ event.visibility ?? DEFAULT_EVENT_VISIBILITY[event.kind as TraceEventKind] ?? "private";
403
+
404
+ // Build the complete event (without hash initially)
405
+ // We use 'as unknown as TraceEvent' because TypeScript cannot infer
406
+ // that adding runtime fields to EventWithoutRuntimeFields<K> produces a valid TraceEvent.
407
+ // The caller ensures the correct event shape via the generic constraint.
408
+ const completeEvent = {
409
+ ...event,
410
+ id: eventId,
411
+ seq,
412
+ timestamp,
413
+ visibility,
414
+ } as unknown as TraceEvent;
415
+
416
+ // Compute event hash
417
+ const eventHash = await computeEventHash(completeEvent);
418
+ completeEvent.hash = eventHash;
419
+
420
+ // Update rolling hash
421
+ const currentState = {
422
+ currentHash: run.rollingHash,
423
+ itemCount: run.events.length,
424
+ };
425
+ const newState = await updateRollingHash(currentState, eventHash);
426
+ run.rollingHash = newState.currentHash;
427
+
428
+ // Add event ID to span's eventIds
429
+ span.eventIds.push(eventId);
430
+
431
+ // Add event to run's events array
432
+ run.events.push(completeEvent);
433
+
434
+ return completeEvent;
435
+ }
436
+
437
+ // =============================================================================
438
+ // SPAN CLOSING
439
+ // =============================================================================
440
+
441
+ /**
442
+ * Close a span, marking it as completed/failed/cancelled.
443
+ *
444
+ * Sets the span's:
445
+ * - status (default "completed")
446
+ * - endedAt timestamp
447
+ * - durationMs (calculated from startedAt to endedAt)
448
+ * - hash (computed from span header + event hashes)
449
+ *
450
+ * @param run - The trace run containing the span (mutated in place)
451
+ * @param spanId - ID of the span to close
452
+ * @param status - Final status (default "completed")
453
+ * @throws Error if span not found or already closed
454
+ *
455
+ * @example
456
+ * ```typescript
457
+ * // Close with default "completed" status
458
+ * await closeSpan(run, span.id);
459
+ *
460
+ * // Close with explicit status
461
+ * await closeSpan(run, span.id, "failed");
462
+ * ```
463
+ */
464
+ export async function closeSpan(
465
+ run: TraceRun,
466
+ spanId: string,
467
+ status: TraceStatus = "completed"
468
+ ): Promise<void> {
469
+ // Find the span
470
+ const span = getSpan(run, spanId);
471
+ if (!span) {
472
+ throw new Error(`Span not found: ${spanId}`);
473
+ }
474
+
475
+ // Validate span is still running
476
+ if (span.status !== "running") {
477
+ throw new Error(`Span already closed: ${spanId} (status: ${span.status})`);
478
+ }
479
+
480
+ // Set final status
481
+ span.status = status;
482
+
483
+ // Set end timestamp
484
+ const endedAt = new Date().toISOString();
485
+ span.endedAt = endedAt;
486
+
487
+ // Calculate duration
488
+ const startTime = new Date(span.startedAt).getTime();
489
+ const endTime = new Date(endedAt).getTime();
490
+ span.durationMs = endTime - startTime;
491
+
492
+ // Get event hashes for this span in seq order
493
+ const spanEvents = getSpanEvents(run, spanId);
494
+ const eventHashes = spanEvents.map((e) => e.hash ?? "");
495
+
496
+ // Compute span hash
497
+ span.hash = await computeSpanHash(span, eventHashes);
498
+ }
499
+
500
+ // =============================================================================
501
+ // TRACE FINALIZATION
502
+ // =============================================================================
503
+
504
+ /**
505
+ * Check if a run is finalized.
506
+ *
507
+ * A run is considered finalized when it has a rootHash set.
508
+ *
509
+ * @param run - The trace run to check
510
+ * @returns true if the run is finalized
511
+ *
512
+ * @example
513
+ * ```typescript
514
+ * if (!isFinalized(run)) {
515
+ * // Can still add spans and events
516
+ * await addEvent(run, span.id, { kind: "command", command: "ls" });
517
+ * }
518
+ * ```
519
+ */
520
+ export function isFinalized(run: TraceRun): boolean {
521
+ return run.rootHash !== undefined;
522
+ }
523
+
524
+ /**
525
+ * Finalize a trace run, computing all final hashes and creating a bundle.
526
+ *
527
+ * Finalization performs:
528
+ * 1. Closes any open spans (with status "completed")
529
+ * 2. Sets run status to "completed"
530
+ * 3. Sets run endedAt and durationMs
531
+ * 4. Builds Merkle tree from spans
532
+ * 5. Computes root hash from rolling hash + span hashes
533
+ * 6. Creates public view (only public spans with their events)
534
+ * 7. Returns complete TraceBundle
535
+ *
536
+ * After finalization, no more spans or events can be added.
537
+ *
538
+ * @param run - The trace run to finalize (mutated in place)
539
+ * @returns Promise resolving to the complete TraceBundle
540
+ * @throws Error if the run is already finalized
541
+ *
542
+ * @example
543
+ * ```typescript
544
+ * // Finalize and get the bundle
545
+ * const bundle = await finalizeTrace(run);
546
+ *
547
+ * // Access the cryptographic commitments
548
+ * console.log(`Root hash: ${bundle.rootHash}`);
549
+ * console.log(`Merkle root: ${bundle.merkleRoot}`);
550
+ *
551
+ * // Access the public view for sharing
552
+ * console.log(`Public spans: ${bundle.publicView.publicSpans.length}`);
553
+ * ```
554
+ */
555
+ export async function finalizeTrace(run: TraceRun): Promise<TraceBundle> {
556
+ // Validate run is not already finalized
557
+ if (isFinalized(run)) {
558
+ throw new Error("Trace run is already finalized");
559
+ }
560
+
561
+ // -------------------------------------------------------------------------
562
+ // Pre-execution manifest enforcement (issue #59)
563
+ // -------------------------------------------------------------------------
564
+ if (run.modelManifest === undefined) {
565
+ if (run.strict) {
566
+ throw new Error(
567
+ "finalizeTrace: strict mode requires a model manifest pinned at createTrace() time"
568
+ );
569
+ }
570
+ // Warn-only path for v0.x — surfaces the missing model-immutability guarantee.
571
+ // Becomes a hard error under strict-by-default in v1.0.
572
+ console.warn(
573
+ "[orynq] finalizeTrace: no model manifest was pinned — model/data immutability " +
574
+ "is NOT proven for this trace. Pass `manifest` to createTrace() (and " +
575
+ "`strict: true` to enforce). This will become an error in v1.0."
576
+ );
577
+ }
578
+
579
+ // Close any open spans
580
+ for (const span of run.spans) {
581
+ if (span.status === "running") {
582
+ await closeSpan(run, span.id, "completed");
583
+ }
584
+ }
585
+
586
+ // Set run status to completed
587
+ run.status = "completed";
588
+
589
+ // Set end timestamp and duration
590
+ const endedAt = new Date().toISOString();
591
+ run.endedAt = endedAt;
592
+ const startTime = new Date(run.startedAt).getTime();
593
+ const endTime = new Date(endedAt).getTime();
594
+ run.durationMs = endTime - startTime;
595
+
596
+ // Build Merkle tree from spans
597
+ const merkleTree = await buildSpanMerkleTree(run.spans, run.events);
598
+
599
+ // Compute root hash from rolling hash + span hashes, binding the pinned
600
+ // model-manifest commitment into the committed root (#59).
601
+ const rootHash = await computeRootHash(
602
+ run.rollingHash,
603
+ run.spans,
604
+ run.modelManifestHash
605
+ );
606
+ run.rootHash = rootHash;
607
+
608
+ // Create public view
609
+ const publicView = createPublicView(run, merkleTree.rootHash);
610
+
611
+ // Build and return the complete bundle
612
+ const bundle: TraceBundle = {
613
+ formatVersion: "1.0",
614
+ publicView,
615
+ privateRun: run,
616
+ merkleRoot: merkleTree.rootHash,
617
+ rootHash,
618
+ };
619
+
620
+ // Surface the pinned model manifest on the bundle (public-safe: hashes only).
621
+ if (run.modelManifestHash !== undefined) {
622
+ bundle.modelManifestHash = run.modelManifestHash;
623
+ }
624
+ if (run.modelManifest !== undefined) {
625
+ bundle.modelManifest = run.modelManifest;
626
+ }
627
+
628
+ return bundle;
629
+ }
630
+
631
+ // =============================================================================
632
+ // PUBLIC VIEW GENERATION
633
+ // =============================================================================
634
+
635
+ /**
636
+ * Create a public view of the trace suitable for external sharing.
637
+ *
638
+ * The public view includes:
639
+ * - Run metadata (id, agentId, timestamps, etc.)
640
+ * - Cryptographic commitments (rootHash, merkleRoot)
641
+ * - Public spans with their events
642
+ * - Hashes of redacted (non-public) spans
643
+ *
644
+ * Private and secret data is excluded, but their hashes are included
645
+ * for verification purposes.
646
+ *
647
+ * @param run - The finalized trace run
648
+ * @param merkleRoot - The Merkle root from the span tree
649
+ * @returns The public view of the trace bundle
650
+ */
651
+ function createPublicView(
652
+ run: TraceRun,
653
+ merkleRoot: string
654
+ ): TraceBundlePublicView {
655
+ // Build event lookup map
656
+ const eventMap = new Map(run.events.map((e) => [e.id, e]));
657
+
658
+ // Separate public spans from non-public
659
+ const publicSpans: AnnotatedSpan[] = [];
660
+ const redactedSpanHashes: Array<{ spanId: string; hash: string }> = [];
661
+
662
+ for (const span of run.spans) {
663
+ if (span.visibility === "public") {
664
+ // Include public spans with their events
665
+ const spanEvents = span.eventIds
666
+ .map((id) => eventMap.get(id))
667
+ .filter((e): e is TraceEvent => e !== undefined)
668
+ // Only include public events within public spans
669
+ .filter((e) => e.visibility === "public")
670
+ .sort((a, b) => a.seq - b.seq);
671
+
672
+ const annotatedSpan: AnnotatedSpan = {
673
+ ...span,
674
+ events: spanEvents,
675
+ };
676
+ publicSpans.push(annotatedSpan);
677
+ } else {
678
+ // Include only the hash for non-public spans
679
+ if (span.hash) {
680
+ redactedSpanHashes.push({
681
+ spanId: span.id,
682
+ hash: span.hash,
683
+ });
684
+ }
685
+ }
686
+ }
687
+
688
+ // Sort public spans by spanSeq
689
+ publicSpans.sort((a, b) => a.spanSeq - b.spanSeq);
690
+
691
+ // Sort redacted span hashes by spanId for consistency
692
+ redactedSpanHashes.sort((a, b) => a.spanId.localeCompare(b.spanId));
693
+
694
+ const publicView: TraceBundlePublicView = {
695
+ runId: run.id,
696
+ agentId: run.agentId,
697
+ schemaVersion: run.schemaVersion,
698
+ startedAt: run.startedAt,
699
+ endedAt: run.endedAt ?? run.startedAt, // Fallback for safety
700
+ durationMs: run.durationMs ?? 0,
701
+ status: run.status,
702
+ totalEvents: run.events.length,
703
+ totalSpans: run.spans.length,
704
+ rootHash: run.rootHash ?? "",
705
+ merkleRoot,
706
+ publicSpans,
707
+ redactedSpanHashes,
708
+ };
709
+
710
+ // Model-state commitment is public-safe (it is only a hash).
711
+ if (run.modelManifestHash !== undefined) {
712
+ publicView.modelManifestHash = run.modelManifestHash;
713
+ }
714
+ if (run.modelManifest !== undefined) {
715
+ publicView.modelManifest = run.modelManifest;
716
+ }
717
+
718
+ return publicView;
719
+ }
720
+
721
+ // =============================================================================
722
+ // UTILITY FUNCTIONS
723
+ // =============================================================================
724
+
725
+ /**
726
+ * Get total event count for a trace run.
727
+ *
728
+ * @param run - The trace run
729
+ * @returns Number of events in the run
730
+ */
731
+ export function getEventCount(run: TraceRun): number {
732
+ return run.events.length;
733
+ }
734
+
735
+ /**
736
+ * Get total span count for a trace run.
737
+ *
738
+ * @param run - The trace run
739
+ * @returns Number of spans in the run
740
+ */
741
+ export function getSpanCount(run: TraceRun): number {
742
+ return run.spans.length;
743
+ }
744
+
745
+ /**
746
+ * Get all root spans (spans without a parent).
747
+ *
748
+ * @param run - The trace run
749
+ * @returns Array of root-level spans
750
+ */
751
+ export function getRootSpans(run: TraceRun): TraceSpan[] {
752
+ return run.spans.filter((s) => s.parentSpanId === undefined);
753
+ }
754
+
755
+ /**
756
+ * Get child spans for a given parent span.
757
+ *
758
+ * @param run - The trace run
759
+ * @param parentSpanId - The parent span ID
760
+ * @returns Array of child spans
761
+ */
762
+ export function getChildSpans(run: TraceRun, parentSpanId: string): TraceSpan[] {
763
+ return run.spans.filter((s) => s.parentSpanId === parentSpanId);
764
+ }
765
+
766
+ /**
767
+ * Get an event by ID from a run.
768
+ *
769
+ * @param run - The trace run
770
+ * @param eventId - The event ID to find
771
+ * @returns The event if found, undefined otherwise
772
+ */
773
+ export function getEvent(run: TraceRun, eventId: string): TraceEvent | undefined {
774
+ return run.events.find((e) => e.id === eventId);
775
+ }
776
+
777
+ /**
778
+ * Get all events of a specific kind from a run.
779
+ *
780
+ * @param run - The trace run
781
+ * @param kind - The event kind to filter by
782
+ * @returns Array of events matching the kind
783
+ */
784
+ export function getEventsByKind<K extends TraceEventKind>(
785
+ run: TraceRun,
786
+ kind: K
787
+ ): Extract<TraceEvent, { kind: K }>[] {
788
+ return run.events.filter(
789
+ (e): e is Extract<TraceEvent, { kind: K }> => e.kind === kind
790
+ );
791
+ }