@dxos/react-ui-trace 0.11.1

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 (148) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +3 -0
  3. package/dist/lib/index.mjs +2868 -0
  4. package/dist/lib/index.mjs.map +1 -0
  5. package/dist/lib/testing.mjs +338 -0
  6. package/dist/lib/testing.mjs.map +1 -0
  7. package/dist/lib/translations.mjs +19 -0
  8. package/dist/lib/translations.mjs.map +1 -0
  9. package/dist/types/src/components/Gantt/Gantt.d.ts +68 -0
  10. package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -0
  11. package/dist/types/src/components/Gantt/Gantt.stories.d.ts +30 -0
  12. package/dist/types/src/components/Gantt/Gantt.stories.d.ts.map +1 -0
  13. package/dist/types/src/components/Gantt/index.d.ts +2 -0
  14. package/dist/types/src/components/Gantt/index.d.ts.map +1 -0
  15. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +32 -0
  16. package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -0
  17. package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts +8 -0
  18. package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -0
  19. package/dist/types/src/components/ProcessTree/index.d.ts +2 -0
  20. package/dist/types/src/components/ProcessTree/index.d.ts.map +1 -0
  21. package/dist/types/src/components/Timeline/Timeline.d.ts +50 -0
  22. package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -0
  23. package/dist/types/src/components/Timeline/Timeline.stories.d.ts +24 -0
  24. package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -0
  25. package/dist/types/src/components/Timeline/index.d.ts +4 -0
  26. package/dist/types/src/components/Timeline/index.d.ts.map +1 -0
  27. package/dist/types/src/components/Timeline/timeline-options.d.ts +9 -0
  28. package/dist/types/src/components/Timeline/timeline-options.d.ts.map +1 -0
  29. package/dist/types/src/components/Timeline/timeline-printer.d.ts +7 -0
  30. package/dist/types/src/components/Timeline/timeline-printer.d.ts.map +1 -0
  31. package/dist/types/src/components/Timeline/timeline-printer.test.d.ts +2 -0
  32. package/dist/types/src/components/Timeline/timeline-printer.test.d.ts.map +1 -0
  33. package/dist/types/src/components/TracePanel/TracePanel.d.ts +28 -0
  34. package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -0
  35. package/dist/types/src/components/TracePanel/dxn-extractor.d.ts +19 -0
  36. package/dist/types/src/components/TracePanel/dxn-extractor.d.ts.map +1 -0
  37. package/dist/types/src/components/TracePanel/dxn-extractor.test.d.ts +2 -0
  38. package/dist/types/src/components/TracePanel/dxn-extractor.test.d.ts.map +1 -0
  39. package/dist/types/src/components/TracePanel/index.d.ts +4 -0
  40. package/dist/types/src/components/TracePanel/index.d.ts.map +1 -0
  41. package/dist/types/src/components/TracePanel/trace-filter.d.ts +39 -0
  42. package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -0
  43. package/dist/types/src/components/TracePanel/trace-filter.test.d.ts +2 -0
  44. package/dist/types/src/components/TracePanel/trace-filter.test.d.ts.map +1 -0
  45. package/dist/types/src/components/TracePanel/useTraceMenu.d.ts +15 -0
  46. package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -0
  47. package/dist/types/src/components/index.d.ts +5 -0
  48. package/dist/types/src/components/index.d.ts.map +1 -0
  49. package/dist/types/src/execution-graph/execution-graph.d.ts +139 -0
  50. package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -0
  51. package/dist/types/src/execution-graph/execution-graph.test.d.ts +2 -0
  52. package/dist/types/src/execution-graph/execution-graph.test.d.ts.map +1 -0
  53. package/dist/types/src/execution-graph/index.d.ts +4 -0
  54. package/dist/types/src/execution-graph/index.d.ts.map +1 -0
  55. package/dist/types/src/execution-graph/pending-block-status.d.ts +26 -0
  56. package/dist/types/src/execution-graph/pending-block-status.d.ts.map +1 -0
  57. package/dist/types/src/execution-graph/pending-block-status.test.d.ts +2 -0
  58. package/dist/types/src/execution-graph/pending-block-status.test.d.ts.map +1 -0
  59. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +2 -0
  60. package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +1 -0
  61. package/dist/types/src/execution-graph/span-tree.d.ts +106 -0
  62. package/dist/types/src/execution-graph/span-tree.d.ts.map +1 -0
  63. package/dist/types/src/execution-graph/span-tree.test.d.ts +2 -0
  64. package/dist/types/src/execution-graph/span-tree.test.d.ts.map +1 -0
  65. package/dist/types/src/execution-graph/sub-agent-delegation.test.d.ts +2 -0
  66. package/dist/types/src/execution-graph/sub-agent-delegation.test.d.ts.map +1 -0
  67. package/dist/types/src/execution-graph/testing/collect-trace-events.d.ts +40 -0
  68. package/dist/types/src/execution-graph/testing/collect-trace-events.d.ts.map +1 -0
  69. package/dist/types/src/execution-graph/testing/index.d.ts +2 -0
  70. package/dist/types/src/execution-graph/testing/index.d.ts.map +1 -0
  71. package/dist/types/src/hooks/index.d.ts +4 -0
  72. package/dist/types/src/hooks/index.d.ts.map +1 -0
  73. package/dist/types/src/hooks/useExecutionGraph.d.ts +17 -0
  74. package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -0
  75. package/dist/types/src/hooks/useSessionTimeline.d.ts +17 -0
  76. package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -0
  77. package/dist/types/src/hooks/useTraceMessages.d.ts +17 -0
  78. package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -0
  79. package/dist/types/src/index.d.ts +5 -0
  80. package/dist/types/src/index.d.ts.map +1 -0
  81. package/dist/types/src/session-timeline/index.d.ts +3 -0
  82. package/dist/types/src/session-timeline/index.d.ts.map +1 -0
  83. package/dist/types/src/session-timeline/session-timeline.d.ts +19 -0
  84. package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -0
  85. package/dist/types/src/session-timeline/session-timeline.test.d.ts +2 -0
  86. package/dist/types/src/session-timeline/session-timeline.test.d.ts.map +1 -0
  87. package/dist/types/src/session-timeline/types.d.ts +129 -0
  88. package/dist/types/src/session-timeline/types.d.ts.map +1 -0
  89. package/dist/types/src/testing/index.d.ts +5 -0
  90. package/dist/types/src/testing/index.d.ts.map +1 -0
  91. package/dist/types/src/testing/simulated-agent.d.ts +63 -0
  92. package/dist/types/src/testing/simulated-agent.d.ts.map +1 -0
  93. package/dist/types/src/testing/snapshot-playback.d.ts +12 -0
  94. package/dist/types/src/testing/snapshot-playback.d.ts.map +1 -0
  95. package/dist/types/src/testing/test-trace.d.ts +5 -0
  96. package/dist/types/src/testing/test-trace.d.ts.map +1 -0
  97. package/dist/types/src/translations.d.ts +19 -0
  98. package/dist/types/src/translations.d.ts.map +1 -0
  99. package/dist/types/tsconfig.tsbuildinfo +1 -0
  100. package/package.json +77 -0
  101. package/src/components/Gantt/Gantt.stories.tsx +241 -0
  102. package/src/components/Gantt/Gantt.tsx +589 -0
  103. package/src/components/Gantt/index.ts +5 -0
  104. package/src/components/ProcessTree/ProcessTree.stories.tsx +95 -0
  105. package/src/components/ProcessTree/ProcessTree.tsx +265 -0
  106. package/src/components/ProcessTree/index.ts +5 -0
  107. package/src/components/Timeline/Timeline.stories.tsx +326 -0
  108. package/src/components/Timeline/Timeline.tsx +534 -0
  109. package/src/components/Timeline/git.drawio.svg +821 -0
  110. package/src/components/Timeline/index.ts +7 -0
  111. package/src/components/Timeline/timeline-options.ts +27 -0
  112. package/src/components/Timeline/timeline-printer.test.ts +76 -0
  113. package/src/components/Timeline/timeline-printer.ts +223 -0
  114. package/src/components/TracePanel/TracePanel.tsx +248 -0
  115. package/src/components/TracePanel/dxn-extractor.test.ts +153 -0
  116. package/src/components/TracePanel/dxn-extractor.ts +166 -0
  117. package/src/components/TracePanel/index.ts +7 -0
  118. package/src/components/TracePanel/trace-filter.test.ts +137 -0
  119. package/src/components/TracePanel/trace-filter.ts +120 -0
  120. package/src/components/TracePanel/useTraceMenu.ts +95 -0
  121. package/src/components/index.ts +8 -0
  122. package/src/execution-graph/SPEC.md +749 -0
  123. package/src/execution-graph/execution-graph.test.ts +1205 -0
  124. package/src/execution-graph/execution-graph.ts +1076 -0
  125. package/src/execution-graph/index.ts +35 -0
  126. package/src/execution-graph/pending-block-status.test.ts +118 -0
  127. package/src/execution-graph/pending-block-status.ts +88 -0
  128. package/src/execution-graph/remote-snapshot.test.ts +116 -0
  129. package/src/execution-graph/span-tree.test.ts +370 -0
  130. package/src/execution-graph/span-tree.ts +404 -0
  131. package/src/execution-graph/sub-agent-delegation.test.ts +65 -0
  132. package/src/execution-graph/testing/collect-trace-events.ts +76 -0
  133. package/src/execution-graph/testing/index.ts +5 -0
  134. package/src/execution-graph/testing/sub-agent-delegation.json +1285 -0
  135. package/src/hooks/index.ts +7 -0
  136. package/src/hooks/useExecutionGraph.ts +107 -0
  137. package/src/hooks/useSessionTimeline.ts +53 -0
  138. package/src/hooks/useTraceMessages.ts +58 -0
  139. package/src/index.ts +8 -0
  140. package/src/session-timeline/index.ts +6 -0
  141. package/src/session-timeline/session-timeline.test.ts +662 -0
  142. package/src/session-timeline/session-timeline.ts +605 -0
  143. package/src/session-timeline/types.ts +100 -0
  144. package/src/testing/index.ts +8 -0
  145. package/src/testing/simulated-agent.ts +293 -0
  146. package/src/testing/snapshot-playback.ts +45 -0
  147. package/src/testing/test-trace.ts +22 -0
  148. package/src/translations.ts +29 -0
@@ -0,0 +1,404 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Option from 'effect/Option';
6
+ import * as Schema from 'effect/Schema';
7
+
8
+ import * as Trace from '@dxos/compute/Trace';
9
+ import { EID } from '@dxos/keys';
10
+
11
+ /**
12
+ * Default duration a span may sit open with no new events before it's considered abandoned
13
+ * (the runtime crashed, lost connectivity, or otherwise never wrote the closing event).
14
+ */
15
+ export const DEFAULT_SPAN_TIMEOUT_MS = 20 * 60 * 1_000;
16
+
17
+ /**
18
+ * Synthetic id assigned to the root span.
19
+ * The root is a virtual container for top-level spans (those without a parent in the captured trace).
20
+ */
21
+ export const ROOT_SPAN_ID = '<main>';
22
+
23
+ /**
24
+ * Computed metadata for a span.
25
+ * Derived from the meta of the begin event that opened the span.
26
+ */
27
+ export interface SpanMeta {
28
+ readonly pid?: string;
29
+ readonly parentPid?: string;
30
+ readonly processName?: string;
31
+ readonly conversationId?: string;
32
+ readonly triggerId?: string;
33
+ readonly toolCallId?: string;
34
+ readonly runtimeName?: string;
35
+ }
36
+
37
+ /**
38
+ * A span in the trace hierarchy.
39
+ *
40
+ * Each span represents a single bounded unit of work — an operation invocation or an
41
+ * agent request. Spans are opened by `OperationStart` / `Trace.AgentRequestBegin` events and
42
+ * closed by their matching `OperationEnd` / `Trace.AgentRequestEnd` events. A single process
43
+ * (pid) may produce multiple sequential spans (for example, an agent session that serves
44
+ * three prompts produces three sibling spans sharing the agent's pid).
45
+ *
46
+ * `events` contains the bounded events for this span only — child spans' events are not
47
+ * duplicated here. `children` contains sub-spans that opened while this span was active.
48
+ */
49
+ export interface Span {
50
+ readonly id: string;
51
+ readonly meta: SpanMeta;
52
+ readonly events: Trace.FlatEvent[];
53
+ readonly children: Span[];
54
+ }
55
+
56
+ export const BEGIN_EVENT_TYPES = new Set<string>([Trace.OperationStart.key, Trace.AgentRequestBegin.key]);
57
+ export const END_EVENT_TYPES = new Set<string>([Trace.OperationEnd.key, Trace.AgentRequestEnd.key]);
58
+
59
+ export const isSpanBeginEvent = (event: Trace.FlatEvent): boolean => BEGIN_EVENT_TYPES.has(event.type);
60
+ export const isSpanEndEvent = (event: Trace.FlatEvent): boolean => END_EVENT_TYPES.has(event.type);
61
+
62
+ const computeMeta = (rawMeta: Trace.Meta): SpanMeta => ({
63
+ pid: rawMeta.pid,
64
+ parentPid: rawMeta.parentPid,
65
+ processName: rawMeta.processName,
66
+ conversationId: rawMeta.conversation ? EID.getEntityId(EID.parse(rawMeta.conversation.uri)) : undefined,
67
+ triggerId: rawMeta.trigger ? EID.getEntityId(EID.parse(rawMeta.trigger.uri)) : undefined,
68
+ toolCallId: rawMeta.toolCallId,
69
+ runtimeName: rawMeta.runtimeName,
70
+ });
71
+
72
+ interface MutableSpan {
73
+ id: string;
74
+ meta: SpanMeta;
75
+ events: Trace.FlatEvent[];
76
+ children: MutableSpan[];
77
+ firstTimestamp: number;
78
+ }
79
+
80
+ const makeMutableSpan = (id: string, meta: SpanMeta): MutableSpan => ({
81
+ id,
82
+ meta,
83
+ events: [],
84
+ children: [],
85
+ firstTimestamp: Number.POSITIVE_INFINITY,
86
+ });
87
+
88
+ const freezeSpan = (span: MutableSpan): Span => ({
89
+ id: span.id,
90
+ meta: span.meta,
91
+ events: span.events,
92
+ children: span.children.map(freezeSpan),
93
+ });
94
+
95
+ export interface BuildSpanTreeOptions {
96
+ /**
97
+ * If provided, caps how many *non-boundary* events (status updates, partial blocks, etc.)
98
+ * are retained — only the most recent `eventLimit` of them survive. Span boundary events
99
+ * (`OperationStart` / `OperationEnd` / `Trace.AgentRequestBegin` / `Trace.AgentRequestEnd`) are *always*
100
+ * retained regardless of the limit, because dropping them would orphan their child spans
101
+ * and collapse them onto the root branch.
102
+ */
103
+ eventLimit?: number;
104
+
105
+ /**
106
+ * Duration a span may sit open with no new events before it's force-closed with a synthetic
107
+ * end event. Only takes effect when `now` is also provided. Defaults to
108
+ * `DEFAULT_SPAN_TIMEOUT_MS`.
109
+ */
110
+ spanTimeoutMs?: number;
111
+
112
+ /**
113
+ * Reference time used to detect abandoned spans. Force-closing is skipped entirely when this
114
+ * is omitted, so callers that don't pass it (tests, deterministic snapshots) keep the prior
115
+ * behavior of leaving unterminated spans open regardless of their fixture timestamps.
116
+ */
117
+ now?: number;
118
+ }
119
+
120
+ /**
121
+ * Builds a hierarchical span tree from a list of trace messages.
122
+ *
123
+ * Algorithm:
124
+ * 1. Flatten messages into events and sort them chronologically.
125
+ * 2. For each event:
126
+ * - Begin events (`OperationStart`, `Trace.AgentRequestBegin`) open a new span whose parent
127
+ * is the currently-open span of the event's `meta.parentPid`, or the root if there is
128
+ * none. The begin event itself becomes the first event of the new span.
129
+ * - End events (`OperationEnd`, `Trace.AgentRequestEnd`) close the currently-open span for
130
+ * the event's `meta.pid` and become its last event.
131
+ * - Other events attach to the currently-open span for their `meta.pid`, or to the
132
+ * root span if no span is currently open for that pid (or the event has no pid).
133
+ * 3. Sort each span's children by their earliest event timestamp.
134
+ */
135
+ export const buildSpanTree = (messages: readonly Trace.Message[], options: BuildSpanTreeOptions = {}): Span => {
136
+ const allEvents = messages.flatMap((message) => Trace.flatten(message));
137
+ allEvents.sort((a, b) => a.timestamp - b.timestamp);
138
+
139
+ // The limit caps verbose middle events (status/partial-block) only. Span boundary events
140
+ // are always retained so the parent/child structure of older spans isn't dropped — losing a
141
+ // begin event detaches every descendant and collapses them onto the root branch.
142
+ const events = applyEventLimit(allEvents, options.eventLimit);
143
+
144
+ const root = makeMutableSpan(ROOT_SPAN_ID, {});
145
+
146
+ // Currently-open span for each pid.
147
+ // A process may host multiple sequential spans (e.g. successive agent requests), so we
148
+ // track the active one and start a new span on the next begin event.
149
+ const openSpans = new Map<string, MutableSpan>();
150
+
151
+ // Most-recent span for each pid, open OR already closed. Used as the parent fallback for a
152
+ // child process whose parent span has already ended — e.g. a delegated sub-agent runs
153
+ // concurrently and its begin event arrives after the supervisor's turn (its parent) closed.
154
+ // Without this the child would nest at the root and render on its own top-level lane, reusing
155
+ // a sibling's released lane instead of forking off its parent.
156
+ const lastSpanByPid = new Map<string, MutableSpan>();
157
+
158
+ // Every span ever opened, including ones later superseded by a same-pid begin event before
159
+ // getting an end event (§15 "interleaved same-pid begin/end pairs"). `openSpans` only tracks
160
+ // the *current* span per pid, so a superseded span — which will never receive an end event —
161
+ // would otherwise be unreachable by the timeout sweep below.
162
+ const allSpans: MutableSpan[] = [];
163
+
164
+ let spanCounter = 0;
165
+ const allocSpanId = (pid: string): string => {
166
+ const id = `${pid}#${spanCounter}`;
167
+ spanCounter += 1;
168
+ return id;
169
+ };
170
+
171
+ const noteTimestamp = (span: MutableSpan, timestamp: number): void => {
172
+ if (timestamp < span.firstTimestamp) {
173
+ span.firstTimestamp = timestamp;
174
+ }
175
+ };
176
+
177
+ for (const event of events) {
178
+ const pid = event.meta.pid;
179
+
180
+ if (!pid) {
181
+ // Events without a pid attach to the root.
182
+ root.events.push(event);
183
+ noteTimestamp(root, event.timestamp);
184
+ continue;
185
+ }
186
+
187
+ if (BEGIN_EVENT_TYPES.has(event.type)) {
188
+ const parentPid = event.meta.parentPid;
189
+ // Prefer the parent's currently-open span; fall back to its most-recent (closed) span so a
190
+ // child process whose parent has already ended still nests under it (delegated sub-agents).
191
+ const parent = (parentPid && (openSpans.get(parentPid) ?? lastSpanByPid.get(parentPid))) || root;
192
+ const span = makeMutableSpan(allocSpanId(pid), computeMeta(event.meta));
193
+ span.events.push(event);
194
+ noteTimestamp(span, event.timestamp);
195
+ parent.children.push(span);
196
+ allSpans.push(span);
197
+ // The new span supersedes any previously-open span for this pid.
198
+ openSpans.set(pid, span);
199
+ lastSpanByPid.set(pid, span);
200
+ continue;
201
+ }
202
+
203
+ if (END_EVENT_TYPES.has(event.type)) {
204
+ const span = openSpans.get(pid);
205
+ if (span) {
206
+ span.events.push(event);
207
+ noteTimestamp(span, event.timestamp);
208
+ openSpans.delete(pid);
209
+ } else {
210
+ // End event without a matching begin — attach to root so the event isn't lost.
211
+ root.events.push(event);
212
+ noteTimestamp(root, event.timestamp);
213
+ }
214
+ continue;
215
+ }
216
+
217
+ // Non-boundary event: attach to the currently-open span for this pid if any.
218
+ const open = openSpans.get(pid);
219
+ if (open) {
220
+ open.events.push(event);
221
+ noteTimestamp(open, event.timestamp);
222
+ } else {
223
+ root.events.push(event);
224
+ noteTimestamp(root, event.timestamp);
225
+ }
226
+ }
227
+
228
+ // Force-close spans the runtime never reported the end of (crash, dropped connection, etc.) —
229
+ // otherwise they stay open forever and the UI renders them as stuck mid-flight. Walks every
230
+ // span ever opened (not just `openSpans`) so a span superseded by a later same-pid begin event
231
+ // — which never receives its own end event — is force-closed too. Skipped when `now` isn't
232
+ // supplied so callers that don't care about wall-clock staleness are unaffected.
233
+ if (options.now !== undefined) {
234
+ const now = options.now;
235
+ const spanTimeoutMs = options.spanTimeoutMs ?? DEFAULT_SPAN_TIMEOUT_MS;
236
+ for (const span of allSpans) {
237
+ const lastEvent = span.events[span.events.length - 1]!;
238
+ if (isSpanEndEvent(lastEvent)) {
239
+ continue;
240
+ }
241
+ const timeoutAt = lastEvent.timestamp + spanTimeoutMs;
242
+ if (now < timeoutAt) {
243
+ continue;
244
+ }
245
+ const endEvent = makeTimeoutEndEvent(span, timeoutAt);
246
+ span.events.push(endEvent);
247
+ noteTimestamp(span, endEvent.timestamp);
248
+ }
249
+ }
250
+
251
+ // Sort children chronologically for deterministic output.
252
+ const sortChildren = (span: MutableSpan): void => {
253
+ span.children.sort((a, b) => a.firstTimestamp - b.firstTimestamp);
254
+ for (const child of span.children) {
255
+ sortChildren(child);
256
+ }
257
+ };
258
+ sortChildren(root);
259
+
260
+ return freezeSpan(root);
261
+ };
262
+
263
+ /**
264
+ * Message stamped on synthetic end events produced when a span times out.
265
+ */
266
+ const SPAN_TIMEOUT_MESSAGE = 'No end event was recorded for this span; it was closed after a timeout.';
267
+
268
+ /**
269
+ * Synthesizes the end event that closes an abandoned span, matching the event type its begin
270
+ * event opened it with (`OperationStart` → `OperationEnd`, `Trace.AgentRequestBegin` → `Trace.AgentRequestEnd`).
271
+ */
272
+ const makeTimeoutEndEvent = (span: MutableSpan, timestamp: number): Trace.FlatEvent => {
273
+ const beginEvent = span.events[0]!;
274
+ if (beginEvent.type === Trace.AgentRequestBegin.key) {
275
+ return {
276
+ type: Trace.AgentRequestEnd.key,
277
+ timestamp,
278
+ meta: beginEvent.meta,
279
+ isEphemeral: false,
280
+ data: { status: 'interrupted', error: SPAN_TIMEOUT_MESSAGE },
281
+ };
282
+ }
283
+
284
+ // `beginEvent.data` is unvalidated wire data; decode defensively rather than trusting its shape.
285
+ const fallback: Schema.Schema.Type<typeof Trace.OperationStart.schema> = { key: 'unknown' };
286
+ const beginData = Option.getOrElse(
287
+ Schema.decodeUnknownOption(Trace.OperationStart.schema)(beginEvent.data),
288
+ () => fallback,
289
+ );
290
+ return {
291
+ type: Trace.OperationEnd.key,
292
+ timestamp,
293
+ meta: beginEvent.meta,
294
+ isEphemeral: false,
295
+ data: {
296
+ key: beginData.key,
297
+ name: beginData.name,
298
+ icon: beginData.icon,
299
+ outcome: 'failure',
300
+ error: SPAN_TIMEOUT_MESSAGE,
301
+ },
302
+ };
303
+ };
304
+
305
+ /**
306
+ * Caps the number of non-boundary events while retaining every span boundary event.
307
+ *
308
+ * Boundary events define the span tree's structure: dropping a begin event detaches every
309
+ * descendant span (its `parentPid` lookup fails, so children fall back to the root), and
310
+ * dropping an end event leaves a span open and corrupts the open-span map for sibling spans
311
+ * that share a pid. Status/partial-block events are the noisy bulk and the only ones the
312
+ * limit needs to control.
313
+ *
314
+ * The returned array stays in chronological order.
315
+ */
316
+ const applyEventLimit = (events: readonly Trace.FlatEvent[], eventLimit: number | undefined): Trace.FlatEvent[] => {
317
+ if (eventLimit === undefined) {
318
+ return [...events];
319
+ }
320
+
321
+ let nonBoundaryCount = 0;
322
+ for (const event of events) {
323
+ if (!isBoundary(event)) {
324
+ nonBoundaryCount += 1;
325
+ }
326
+ }
327
+ if (nonBoundaryCount <= eventLimit) {
328
+ return [...events];
329
+ }
330
+
331
+ const dropCount = nonBoundaryCount - eventLimit;
332
+ let dropped = 0;
333
+ const result: Trace.FlatEvent[] = [];
334
+ for (const event of events) {
335
+ if (!isBoundary(event) && dropped < dropCount) {
336
+ dropped += 1;
337
+ continue;
338
+ }
339
+ result.push(event);
340
+ }
341
+ return result;
342
+ };
343
+
344
+ const isBoundary = (event: Trace.FlatEvent): boolean =>
345
+ BEGIN_EVENT_TYPES.has(event.type) || END_EVENT_TYPES.has(event.type);
346
+
347
+ /**
348
+ * A single event positioned within the span tree for commit emission.
349
+ */
350
+ export interface SpanEventCursor {
351
+ readonly span: Span;
352
+ readonly event: Trace.FlatEvent;
353
+ readonly eventIndex: number;
354
+ }
355
+
356
+ /**
357
+ * Flattens span-tree events in categorical (depth-first) order: each span's begin events, its
358
+ * child subtrees in sibling order, then its remaining events (middle and end).
359
+ */
360
+ export const collectSpanTreeEventsCategorically = (root: Span): SpanEventCursor[] => {
361
+ const stream: SpanEventCursor[] = [];
362
+
363
+ const visitSpan = (span: Span): void => {
364
+ for (let eventIndex = 0; eventIndex < span.events.length; eventIndex += 1) {
365
+ const event = span.events[eventIndex]!;
366
+ if (isSpanBeginEvent(event)) {
367
+ stream.push({ span, event, eventIndex });
368
+ }
369
+ }
370
+
371
+ for (const child of span.children) {
372
+ visitSpan(child);
373
+ }
374
+
375
+ for (let eventIndex = 0; eventIndex < span.events.length; eventIndex += 1) {
376
+ const event = span.events[eventIndex]!;
377
+ if (!isSpanBeginEvent(event)) {
378
+ stream.push({ span, event, eventIndex });
379
+ }
380
+ }
381
+ };
382
+
383
+ visitSpan(root);
384
+ return stream;
385
+ };
386
+
387
+ /**
388
+ * Walks the span tree in depth-first pre-order, invoking `visit` for each span (root included).
389
+ */
390
+ export const walkSpanTree = (root: Span, visit: (span: Span, depth: number) => void, depth = 0): void => {
391
+ visit(root, depth);
392
+ for (const child of root.children) {
393
+ walkSpanTree(child, visit, depth + 1);
394
+ }
395
+ };
396
+
397
+ /**
398
+ * Returns all spans in the tree in depth-first pre-order, root first.
399
+ */
400
+ export const flattenSpanTree = (root: Span): Span[] => {
401
+ const result: Span[] = [];
402
+ walkSpanTree(root, (span) => result.push(span));
403
+ return result;
404
+ };
@@ -0,0 +1,65 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import * as Trace from '@dxos/compute/Trace';
8
+ import { renderTimelineAscii } from '../components/Timeline/index.ts';
9
+
10
+ import { buildExecutionGraph, collectProcessActivityLines, deriveInFlightActivityLine } from './execution-graph.ts';
11
+ import subAgentFixture from './testing/sub-agent-delegation.json';
12
+
13
+ // Real trace captured from a live supervisor → sub-agent delegation via `dxosDumpTrace()`.
14
+ // External JSON → typed at this boundary; `buildExecutionGraph` reads only `meta`/`events`.
15
+ const messages = (subAgentFixture as unknown as Trace.Message[])
16
+ .slice()
17
+ .sort((a, b) => (a.events[0]?.timestamp ?? 0) - (b.events[0]?.timestamp ?? 0));
18
+
19
+ describe('sub-agent delegation fixture', () => {
20
+ // Ground-truth render of a concurrent supervisor + sub-agent trace. Used to iterate on the
21
+ // execution-graph lane layout (the sub-agent should occupy its own lane throughout).
22
+ test('renders the delegation timeline', ({ expect }) => {
23
+ const { commits, branches } = buildExecutionGraph({ traceMessages: messages });
24
+ expect(`\n${renderTimelineAscii(commits, branches)}\n`).toMatchInlineSnapshot(`
25
+ "
26
+ ● [atom] Agent processing request...
27
+ ├──● [info] Get Agent Context
28
+ │ ● [info] Get Agent Context
29
+ │ ● [check-square-offset] Update tasks
30
+ │ ● [info] Get Agent Context
31
+ │ ● [share-network] Delegate task
32
+ │ ● [info] Get Agent Context
33
+ │ ├──● [brain] Run Routine
34
+ │ │ ● [info] Get Agent Context
35
+ │ │ ● [info] Get Agent Context
36
+ │ │ ● [file-text] Create
37
+ │ │ ● [info] Get Agent Context
38
+ │ │ ● [plus] Add artifact - Error
39
+ │ │ ● [info] Get Agent Context
40
+ │ │ ● [info] Get Agent Context
41
+ │ │ ● [user] Complete the following task and report the result concisely.
42
+ │ │ ● [wrench] completeJob
43
+ ◆──┼──╯ [brain] Run Routine
44
+ │ ● [user] create a sub-agent that creates a haiku in a new document
45
+ ◆──╯ [atom] Agent completed request
46
+ ● [pencil] Update Chat Name
47
+ "
48
+ `);
49
+ });
50
+
51
+ test('collects sub-agent activity lines for a delegated process pid', ({ expect }) => {
52
+ const subAgentPid = 'cf8f7243-5b1d-4902-b158-70d9107d5f43';
53
+ const lines = collectProcessActivityLines(messages, subAgentPid);
54
+ expect(lines.length).toBeGreaterThan(0);
55
+ expect(lines).toContain('Run Routine');
56
+ expect(lines[lines.length - 1]).toBe('Run Routine');
57
+ });
58
+
59
+ test('derives in-flight operation before completion', ({ expect }) => {
60
+ const subAgentPid = 'cf8f7243-5b1d-4902-b158-70d9107d5f43';
61
+ const partial = messages.slice(0, Math.floor(messages.length / 2));
62
+ const line = deriveInFlightActivityLine(partial, subAgentPid);
63
+ expect(line).toBeDefined();
64
+ });
65
+ });
@@ -0,0 +1,76 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Cause from 'effect/Cause';
6
+ import * as Context from 'effect/Context';
7
+ import * as Effect from 'effect/Effect';
8
+ import * as Layer from 'effect/Layer';
9
+
10
+ import * as Trace from '@dxos/compute/Trace';
11
+
12
+ /**
13
+ * Internal tag carrying the monotonic clock created by {@link collectTraceEvents}. Nested
14
+ * {@link withMeta} scopes read it so every emitted message gets a strictly increasing
15
+ * timestamp regardless of how many writes happened in the same wall-clock millisecond.
16
+ */
17
+ class TestClock extends Context.Service<TestClock, () => number>()(
18
+ '@dxos/plugin-assistant/CollectTraceEvents/TestClock',
19
+ ) {}
20
+
21
+ /**
22
+ * Runs the given Effect with a mock {@link Trace.TraceService} and {@link Trace.TraceSink}
23
+ * whose writes accumulate into an in-memory list of {@link Trace.Message}s.
24
+ *
25
+ * Each `Trace.write(...)` call inside the effect produces one `Trace.Message` carrying the
26
+ * meta of the innermost {@link withMeta} scope (or empty meta if none is provided). Useful
27
+ * for building deterministic span fixtures without spinning up a feed sink.
28
+ *
29
+ * Timestamps are produced by an internal monotonic counter so multiple synchronous writes
30
+ * are ordered in insertion order — wall-clock `Date.now()` would otherwise tie and corrupt
31
+ * the resulting graph for fast tests.
32
+ *
33
+ * The effect is run synchronously; any failure is rethrown so callers don't pay error
34
+ * boilerplate in tests.
35
+ */
36
+ export const collectTraceEvents = <A, E>(
37
+ effect: Effect.Effect<A, E, Trace.TraceService | Trace.TraceSink | TestClock>,
38
+ ): Trace.Message[] => {
39
+ const messages: Trace.Message[] = [];
40
+ let counter = 0;
41
+ const clock = () => {
42
+ counter += 1;
43
+ return counter;
44
+ };
45
+
46
+ const sinkLayer: Layer.Layer<Trace.TraceSink> = Layer.succeed(Trace.TraceSink, {
47
+ write: (message) => {
48
+ messages.push(message);
49
+ },
50
+ });
51
+ const clockLayer: Layer.Layer<TestClock> = Layer.succeed(TestClock, clock);
52
+ const traceLayer: Layer.Layer<Trace.TraceService> = Trace.testTraceService({ clock }).pipe(Layer.provide(sinkLayer));
53
+ const layer = Layer.mergeAll(traceLayer, sinkLayer, clockLayer);
54
+
55
+ const exit = Effect.runSyncExit(effect.pipe(Effect.provide(layer)));
56
+ if (exit._tag === 'Failure') {
57
+ throw new Error(`collectTraceEvents: effect failed\n${Cause.pretty(exit.cause)}`);
58
+ }
59
+ return messages;
60
+ };
61
+
62
+ /**
63
+ * Wraps an effect so that {@link Trace.write} calls inside it tag their messages with the
64
+ * given {@link Trace.Meta}. Nested `withMeta` scopes shadow the outer meta for their inner
65
+ * effect — exactly the pattern needed to fixture multi-process traces (e.g. an agent
66
+ * process containing nested operation processes).
67
+ *
68
+ * Implemented by re-providing a fresh {@link Trace.TraceService} layer over the existing
69
+ * {@link Trace.TraceSink} and shared monotonic clock, so all messages still flow into the
70
+ * same accumulator with monotonically increasing timestamps.
71
+ */
72
+ export const withMeta = <A, E, R>(
73
+ meta: Trace.Meta,
74
+ effect: Effect.Effect<A, E, R>,
75
+ ): Effect.Effect<A, E, Exclude<R, Trace.TraceService> | Trace.TraceSink | TestClock> =>
76
+ Effect.flatMap(TestClock, (clock) => Effect.provide(effect, Trace.testTraceService({ meta, clock })));
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export { collectTraceEvents, withMeta } from './collect-trace-events.ts';