@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,605 @@
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 Process from '@dxos/compute/Process';
9
+ import * as Trace from '@dxos/compute/Trace';
10
+ import { Annotation } from '@dxos/echo';
11
+ import { EID } from '@dxos/keys';
12
+ import { Task } from '@dxos/types';
13
+
14
+ import { type Span, buildSpanTree, flattenSpanTree } from '../execution-graph/index.ts';
15
+
16
+ import {
17
+ type Lane,
18
+ type LaneStatus,
19
+ type Marker,
20
+ type Session,
21
+ type SessionTimeline,
22
+ type TokenUsage,
23
+ } from './types.ts';
24
+
25
+ export interface BuildSessionTimelineInput {
26
+ traceMessages: readonly Trace.Message[];
27
+ processes?: readonly Process.Info[];
28
+ /** The sessions to draw; each contributes a lane and its checklist's task lanes. */
29
+ sessions?: readonly Session[];
30
+ tasks?: readonly Task.Task[];
31
+ /** Reference time; extends the range past open lanes. */
32
+ now?: number;
33
+ }
34
+
35
+ const ACTIVE_STATES = new Set<Process.State>([Process.State.RUNNING, Process.State.HYBERNATING]);
36
+
37
+ /** How long an open lane may be silent before the axis stops following `now`. */
38
+ const OPEN_LANE_STALE_MS = 10 * 60_000;
39
+
40
+ const TASK_STATUS: Partial<Record<Task.Status, LaneStatus>> = {
41
+ todo: 'pending',
42
+ backlog: 'pending',
43
+ blocked: 'blocked',
44
+ started: 'running',
45
+ review: 'review',
46
+ done: 'done',
47
+ failed: 'failed',
48
+ };
49
+
50
+ /** Entity id of an ECHO URI, the join key between a process target, a trace meta and a chat. */
51
+ const entityKey = (uri: string): string => {
52
+ const eid = EID.tryParse(uri);
53
+ return (eid && EID.getEntityId(eid)) ?? uri;
54
+ };
55
+
56
+ const eventFeedKey = (event: Trace.FlatEvent): string | undefined =>
57
+ event.meta.conversation ? entityKey(event.meta.conversation.uri) : undefined;
58
+
59
+ const decode = <S extends Schema.ConstraintDecoder<unknown>>(schema: S, data: unknown): S['Type'] | undefined =>
60
+ Option.getOrUndefined(Schema.decodeUnknownOption(schema)(data));
61
+
62
+ type MutableLane = { -readonly [K in keyof Lane]: Lane[K] };
63
+
64
+ const sessionLaneId = (key: string): string => `session:${key}`;
65
+ const taskLaneId = (taskId: string): string => `task:${taskId}`;
66
+
67
+ interface SessionSource {
68
+ key: string;
69
+ label: string;
70
+ session?: Session;
71
+ pids: Set<string>;
72
+ }
73
+
74
+ /**
75
+ * The stretch of a session during which one task was the active one, bounded by the status events
76
+ * the planning tool writes. Everything the session did inside it belongs to that task.
77
+ */
78
+ interface TaskSegment {
79
+ taskId: string;
80
+ laneId: string;
81
+ start: number;
82
+ end?: number;
83
+ }
84
+
85
+ /**
86
+ * Cuts a session's events into one segment per task worked, from the status events its task tools
87
+ * write. Only tasks in `taskIds` — the session's own checklist — take part: an agent is free to move
88
+ * a task belonging to nothing on this chart, and such an event must not close the segment that is
89
+ * open or move the boundary.
90
+ *
91
+ * A `started` event opens a segment and closes the one still open — the agent keeps exactly one task
92
+ * in progress. Any other status ends the agent's work on the task: `todo` puts it down as surely as
93
+ * `done`, and whatever the agent does next is no longer that task's. A task can also finish without
94
+ * a start: delegation marks every task it hands over
95
+ * `started` before the agent's first turn, so the run's only event for that task is the one closing
96
+ * it. Such a task gets the stretch since the last boundary, which is where its work happened — but
97
+ * only on the transition out of `started` and only while no other task holds the stretch: a task
98
+ * merely dismissed (`todo` → `blocked`) claims nothing, a second close (`review` → `done`) mints
99
+ * nothing, and a close arriving after the next task has started leaves that task's segment alone
100
+ * rather than overlapping it.
101
+ */
102
+ const buildTaskSegments = (
103
+ events: readonly Trace.FlatEvent[],
104
+ sessionStart: number | undefined,
105
+ taskIds: ReadonlySet<string>,
106
+ ): TaskSegment[] => {
107
+ const segments: TaskSegment[] = [];
108
+ let open: TaskSegment | undefined;
109
+ let boundary = sessionStart;
110
+ const close = (segment: TaskSegment, timestamp: number): void => {
111
+ segment.end = timestamp;
112
+ boundary = timestamp;
113
+ if (open === segment) {
114
+ open = undefined;
115
+ }
116
+ };
117
+
118
+ for (const event of events) {
119
+ if (event.type !== Trace.TaskStatusChanged.key) {
120
+ continue;
121
+ }
122
+ const data = decode(Trace.TaskStatusChanged.schema, event.data);
123
+ if (!data || !taskIds.has(data.taskId)) {
124
+ continue;
125
+ }
126
+ if (data.status === 'started') {
127
+ if (open && open.taskId !== data.taskId) {
128
+ close(open, event.timestamp);
129
+ }
130
+ if (!open) {
131
+ open = { taskId: data.taskId, laneId: taskLaneId(data.taskId), start: event.timestamp };
132
+ segments.push(open);
133
+ boundary = event.timestamp;
134
+ }
135
+ } else {
136
+ const started = segments.findLast((candidate) => candidate.taskId === data.taskId && candidate.end === undefined);
137
+ if (started) {
138
+ close(started, event.timestamp);
139
+ } else if (data.previousStatus === 'started' && open === undefined) {
140
+ const segment = {
141
+ taskId: data.taskId,
142
+ laneId: taskLaneId(data.taskId),
143
+ start: Math.min(boundary ?? event.timestamp, event.timestamp),
144
+ };
145
+ segments.push(segment);
146
+ close(segment, event.timestamp);
147
+ }
148
+ }
149
+ }
150
+ return segments;
151
+ };
152
+
153
+ /**
154
+ * The segment an event belongs to. Segments meet where one task starts over another, so a
155
+ * status event is matched to its own task's segment, and any other event at a shared boundary
156
+ * goes to the later segment — the newcomer's start is the first thing the agent did on it.
157
+ */
158
+ const segmentFor = (segments: readonly TaskSegment[] | undefined, event: Trace.FlatEvent): TaskSegment | undefined => {
159
+ if (!segments) {
160
+ return undefined;
161
+ }
162
+ const contains = (segment: TaskSegment) =>
163
+ event.timestamp >= segment.start && event.timestamp <= (segment.end ?? Number.MAX_SAFE_INTEGER);
164
+ if (event.type === Trace.TaskStatusChanged.key) {
165
+ const data = decode(Trace.TaskStatusChanged.schema, event.data);
166
+ return data && segments.findLast((segment) => segment.taskId === data.taskId && contains(segment));
167
+ }
168
+ return segments.findLast(contains);
169
+ };
170
+
171
+ interface SubAgentSpan {
172
+ span: Span;
173
+ pid: string;
174
+ startEvent: Trace.FlatEvent;
175
+ }
176
+
177
+ /**
178
+ * Builds the gantt-shaped view of one or more assistant sessions from their trace: session lanes
179
+ * (supervisors and the sub-agents they delegate to), task lanes, and the markers on each.
180
+ */
181
+ export const buildSessionTimeline = ({
182
+ traceMessages,
183
+ processes = [],
184
+ sessions,
185
+ tasks = [],
186
+ now,
187
+ }: BuildSessionTimelineInput): SessionTimeline => {
188
+ const root = buildSpanTree(traceMessages);
189
+ const spans = flattenSpanTree(root);
190
+ const events = spans.flatMap((span) => span.events).sort((a, b) => a.timestamp - b.timestamp);
191
+ const requestBegins = events.filter(
192
+ (event): event is Trace.FlatEvent & { meta: { pid: string } } =>
193
+ event.type === Trace.AgentRequestBegin.key && event.meta.pid !== undefined,
194
+ );
195
+
196
+ // The agent process targets the chat; its trace meta carries the chat's feed.
197
+ const agentPidsByChat = new Map<string, string[]>();
198
+ for (const process of processes) {
199
+ if (!Process.isHarnessHost(process)) {
200
+ continue;
201
+ }
202
+ const target = Option.getOrUndefined(
203
+ Annotation.getDictionary(process.params.annotations, Process.TargetAnnotation),
204
+ );
205
+ if (target !== undefined) {
206
+ const key = entityKey(target.toString());
207
+ agentPidsByChat.set(key, [...(agentPidsByChat.get(key) ?? []), process.pid]);
208
+ }
209
+ }
210
+
211
+ const sources: SessionSource[] = [];
212
+ if (sessions) {
213
+ for (const session of sessions) {
214
+ if (session.taskIds.length === 0) {
215
+ continue;
216
+ }
217
+ const feed = session.feedId;
218
+ const pids = new Set<string>(agentPidsByChat.get(entityKey(session.uri)) ?? []);
219
+ for (const event of requestBegins) {
220
+ if (feed !== undefined && eventFeedKey(event) === feed) {
221
+ pids.add(event.meta.pid);
222
+ }
223
+ }
224
+ // A feed or object id is not a name: an unnamed chat reads as the session it is until the
225
+ // naming turn lands.
226
+ sources.push({ key: session.id, label: session.label?.trim() || 'Session', session, pids });
227
+ }
228
+ } else {
229
+ // Without chats, the conversation feed groups a session's pids; a trace with no conversation
230
+ // meta falls back to one session per agent pid.
231
+ const byKey = new Map<string, SessionSource>();
232
+ for (const event of requestBegins) {
233
+ const pid = event.meta.pid;
234
+ const key = eventFeedKey(event) ?? pid;
235
+ const source = byKey.get(key) ?? {
236
+ key,
237
+ label: eventFeedKey(event) ?? event.meta.processName ?? pid,
238
+ pids: new Set<string>(),
239
+ };
240
+ source.pids.add(pid);
241
+ byKey.set(key, source);
242
+ }
243
+ sources.push(...byKey.values());
244
+ }
245
+
246
+ const processByPid = new Map<string, Process.Info>(processes.map((process) => [process.pid, process]));
247
+ const taskById = new Map(tasks.map((task) => [task.id, task]));
248
+ const laneByPid = new Map<string, string>();
249
+ const lanes: MutableLane[] = [];
250
+ const markers: Marker[] = [];
251
+ const childSessions: { lane: MutableLane; sessionLaneId: string }[] = [];
252
+ const replacedTaskLanes = new Map<string, string>();
253
+ /** Per session lane: the task segments its markers are attributed to. */
254
+ const segmentsBySession = new Map<string, TaskSegment[]>();
255
+
256
+ for (const source of sources) {
257
+ const laneId = sessionLaneId(source.key);
258
+ const sessionEvents = events.filter((event) => event.meta.pid && source.pids.has(event.meta.pid));
259
+ const begins = sessionEvents.filter((event) => event.type === Trace.AgentRequestBegin.key);
260
+ const ends = sessionEvents.filter((event) => event.type === Trace.AgentRequestEnd.key);
261
+ const requestOpen = begins.length > ends.length;
262
+ const processActive = [...source.pids].some((pid) => {
263
+ const process = processByPid.get(pid);
264
+ return process !== undefined && ACTIVE_STATES.has(process.state);
265
+ });
266
+ const lastEnd = ends.at(-1);
267
+ const lastEndStatus = lastEnd ? decode(Trace.AgentRequestEnd.schema, lastEnd.data)?.status : undefined;
268
+ // An unmatched request begin is a run in flight only while its process is; with the process
269
+ // recorded as ended, the run died before writing its end, so the lane closes at its last event
270
+ // rather than staying open (and stretching the chart) indefinitely.
271
+ const processKnown = [...source.pids].some((pid) => processByPid.has(pid));
272
+ const died = requestOpen && processKnown && !processActive;
273
+ const open = requestOpen && !died;
274
+ const status: LaneStatus =
275
+ processActive || open ? 'running' : died || lastEndStatus === 'error' ? 'failed' : 'done';
276
+
277
+ lanes.push({
278
+ id: laneId,
279
+ kind: 'session',
280
+ label: source.label,
281
+ status,
282
+ start: begins[0]?.timestamp,
283
+ end: open ? undefined : died ? sessionEvents.at(-1)?.timestamp : lastEnd?.timestamp,
284
+ sessionId: source.session?.id,
285
+ pid: [...source.pids].at(-1),
286
+ });
287
+ for (const pid of source.pids) {
288
+ laneByPid.set(pid, laneId);
289
+ }
290
+
291
+ // The spawn event is the only durable pid ↔ task pairing; a sub-agent's own trace carries
292
+ // neither the task nor the conversation.
293
+ const taskByPid = new Map<string, string>();
294
+ for (const event of sessionEvents) {
295
+ if (event.type === Trace.DelegationSpawned.key) {
296
+ const data = decode(Trace.DelegationSpawned.schema, event.data);
297
+ if (data) {
298
+ taskByPid.set(data.pid, data.taskId);
299
+ }
300
+ }
301
+ }
302
+
303
+ // A child span is a sub-agent (not a tool call) when it was spawned as one or emits its own
304
+ // content blocks.
305
+ const subAgentSpans: SubAgentSpan[] = spans.flatMap((span) => {
306
+ const startEvent = span.events[0];
307
+ const pid = span.meta.pid;
308
+ if (
309
+ pid === undefined ||
310
+ span.meta.parentPid === undefined ||
311
+ !source.pids.has(span.meta.parentPid) ||
312
+ startEvent?.type !== Trace.OperationStart.key
313
+ ) {
314
+ return [];
315
+ }
316
+ const isAgent = taskByPid.has(pid) || span.events.some((event) => event.type === Trace.CompleteBlock.key);
317
+ return isAgent ? [{ span, pid, startEvent }] : [];
318
+ });
319
+
320
+ const chatTasks = (source.session?.taskIds ?? [])
321
+ .map((id) => taskById.get(id))
322
+ .filter((task): task is Task.Task => task !== undefined);
323
+ // Only a dependency on the checklist gets a lane, so `blockedOn` never names a lane that is
324
+ // not drawn.
325
+ const chatTaskIds = new Set(chatTasks.map((task) => task.id));
326
+ const taskLanes = new Map<string, MutableLane>();
327
+ for (const task of chatTasks) {
328
+ const taskLane: MutableLane = {
329
+ id: taskLaneId(task.id),
330
+ kind: 'task',
331
+ label: task.title,
332
+ status: taskLaneStatus(task, tasks),
333
+ parentId: laneId,
334
+ sessionId: source.session?.id,
335
+ taskId: task.id,
336
+ };
337
+ const blockedOn = (task.dependsOn ?? [])
338
+ .map((ref) => Task.refEntityId(ref))
339
+ .filter((id): id is string => id !== undefined && chatTaskIds.has(id))
340
+ .map(taskLaneId);
341
+ if (blockedOn.length > 0) {
342
+ taskLane.blockedOn = blockedOn;
343
+ }
344
+ taskLanes.set(task.id, taskLane);
345
+ lanes.push(taskLane);
346
+ }
347
+
348
+ // Spawned pids without a trace yet still get a lane from their process.
349
+ const subAgentPids = new Set<string>([...subAgentSpans.map(({ pid }) => pid), ...taskByPid.keys()]);
350
+
351
+ // The status events bound each task's stretch of the session, giving its lane a span of its own
352
+ // and a node where it started and finished. A status tool runs as a child process of the agent,
353
+ // so its events carry their own pid and reach the session through `parentPid`; a spawned
354
+ // sub-agent's do too, and those belong to its own lane rather than cutting this one.
355
+ const ownEvents = events.filter(
356
+ (event) =>
357
+ (event.meta.pid !== undefined && source.pids.has(event.meta.pid)) ||
358
+ (event.meta.parentPid !== undefined &&
359
+ source.pids.has(event.meta.parentPid) &&
360
+ !(event.meta.pid !== undefined && subAgentPids.has(event.meta.pid))),
361
+ );
362
+ const segments = buildTaskSegments(ownEvents, begins[0]?.timestamp, chatTaskIds);
363
+ segmentsBySession.set(laneId, segments);
364
+ for (const segment of segments) {
365
+ const taskLane = taskLanes.get(segment.taskId);
366
+ if (!taskLane) {
367
+ continue;
368
+ }
369
+ taskLane.start = Math.min(taskLane.start ?? segment.start, segment.start);
370
+ // An open segment leaves the lane open, even if an earlier one closed.
371
+ taskLane.end = segment.end === undefined ? undefined : Math.max(taskLane.end ?? segment.end, segment.end);
372
+ }
373
+
374
+ for (const subPid of subAgentPids) {
375
+ const match = subAgentSpans.find((candidate) => candidate.pid === subPid);
376
+ const process = processByPid.get(subPid);
377
+ if (!match && !process) {
378
+ continue;
379
+ }
380
+ const taskId = taskByPid.get(subPid);
381
+ const task = taskId === undefined ? undefined : taskById.get(taskId);
382
+ const startName = match ? decode(Trace.OperationStart.schema, match.startEvent.data)?.name : undefined;
383
+ const endEvent = match?.span.events.find((event) => event.type === Trace.OperationEnd.key);
384
+ const start = match?.startEvent.timestamp ?? process?.startedAt;
385
+ const end = endEvent?.timestamp ?? (process ? Option.getOrUndefined(process.completedAt) : undefined);
386
+ const subLane: MutableLane = {
387
+ id: sessionLaneId(subPid),
388
+ kind: 'session',
389
+ label: task?.title ?? startName ?? process?.params.name ?? subPid,
390
+ status: subSessionStatus(endEvent, process),
391
+ start,
392
+ end,
393
+ parentId: laneId,
394
+ sessionId: source.session?.id,
395
+ taskId,
396
+ pid: subPid,
397
+ };
398
+ laneByPid.set(subPid, subLane.id);
399
+ childSessions.push({ lane: subLane, sessionLaneId: laneId });
400
+ // A delegated task IS its child session: the session lane takes the task lane's place and
401
+ // its dependencies, so a task is either worked in-session (a task lane) or spawned (a session).
402
+ const taskLane = taskId === undefined ? undefined : taskLanes.get(taskId);
403
+ if (taskLane) {
404
+ subLane.blockedOn = taskLane.blockedOn;
405
+ lanes.splice(lanes.indexOf(taskLane), 1, subLane);
406
+ replacedTaskLanes.set(taskLane.id, subLane.id);
407
+ } else {
408
+ lanes.push(subLane);
409
+ }
410
+ }
411
+ }
412
+
413
+ // A delegated task IS its child session, drawn with the child's own span; the parent's markers in
414
+ // that stretch are the parent's work of handing it over, so the segment goes rather than moving.
415
+ for (const [sessionId, segments] of segmentsBySession) {
416
+ segmentsBySession.set(
417
+ sessionId,
418
+ segments.filter((segment) => !replacedTaskLanes.has(segment.laneId)),
419
+ );
420
+ }
421
+
422
+ // Dependencies named the task lane; they follow it to the session that replaced it.
423
+ for (const lane of lanes) {
424
+ if (lane.blockedOn) {
425
+ lane.blockedOn = lane.blockedOn.map((id) => replacedTaskLanes.get(id) ?? id);
426
+ }
427
+ }
428
+
429
+ // Markers and token totals, attributed to the lane owning the event's pid or its parent pid.
430
+ const tokens = new Map<string, { usage: TokenUsage; toolCalls: number }>();
431
+ const spawnMarkerByPid = new Map<string, string>();
432
+ for (const event of events) {
433
+ const laneId =
434
+ (event.meta.pid && laneByPid.get(event.meta.pid)) ??
435
+ (event.meta.parentPid && laneByPid.get(event.meta.parentPid)) ??
436
+ undefined;
437
+ if (laneId === undefined) {
438
+ continue;
439
+ }
440
+ // Everything the session did while a task was active is that task's, so the session bar keeps
441
+ // only what brackets the whole run (its request markers) and the timeline reads per task.
442
+ const markerLaneId =
443
+ event.type === Trace.AgentRequestBegin.key || event.type === Trace.AgentRequestEnd.key
444
+ ? laneId
445
+ : (segmentFor(segmentsBySession.get(laneId), event)?.laneId ?? laneId);
446
+ const marker = toMarker(event, `${markerLaneId}:${markers.length}`, markerLaneId);
447
+ if (marker) {
448
+ markers.push(marker);
449
+ if (marker.kind === 'delegation') {
450
+ const data = decode(Trace.DelegationSpawned.schema, event.data);
451
+ if (data) {
452
+ spawnMarkerByPid.set(data.pid, marker.id);
453
+ }
454
+ }
455
+ }
456
+ if (event.type === Trace.CompleteBlock.key) {
457
+ const data = decode(Trace.CompleteBlock.schema, event.data);
458
+ if (data?.block._tag === 'stats') {
459
+ const entry = tokens.get(laneId) ?? { usage: { input: 0, output: 0, total: 0 }, toolCalls: 0 };
460
+ // A provider that reports no total still reports the parts.
461
+ const input = data.block.usage?.inputTokens ?? 0;
462
+ const output = data.block.usage?.outputTokens ?? 0;
463
+ const total = data.block.usage?.totalTokens ?? input + output;
464
+ entry.usage = {
465
+ input: entry.usage.input + input,
466
+ output: entry.usage.output + output,
467
+ total: entry.usage.total + total,
468
+ };
469
+ entry.toolCalls += data.block.toolCalls ?? 0;
470
+ tokens.set(laneId, entry);
471
+ }
472
+ }
473
+ }
474
+ for (const lane of lanes) {
475
+ const entry = tokens.get(lane.id);
476
+ if (entry) {
477
+ lane.tokens = entry.usage;
478
+ lane.toolCalls = entry.toolCalls;
479
+ }
480
+ }
481
+
482
+ // The connector starts at the spawn marker, else at the supervisor's last node before the child began.
483
+ for (const { lane, sessionLaneId } of childSessions) {
484
+ const markerId =
485
+ (lane.pid && spawnMarkerByPid.get(lane.pid)) ??
486
+ markers.filter((marker) => marker.laneId === sessionLaneId && marker.timestamp <= (lane.start ?? 0)).at(-1)?.id;
487
+ if (markerId !== undefined) {
488
+ lane.delegatedFrom = { laneId: sessionLaneId, markerId };
489
+ }
490
+ }
491
+
492
+ // A session working exactly one task is that task: the two lanes would carry the same label,
493
+ // so the task lane folds into the session's (which keeps its run span and totals, and takes the
494
+ // task's id and status so it still reads as the work). Only a lone in-session task, with nothing
495
+ // depending on it and no spawned child, folds — a checklist of several stays a tree.
496
+ for (const session of lanes.filter((lane) => lane.kind === 'session' && lane.parentId === undefined)) {
497
+ const children = lanes.filter((lane) => lane.parentId === session.id);
498
+ const [task] = children;
499
+ if (children.length !== 1 || task.kind !== 'task' || lanes.some((lane) => lane.blockedOn?.includes(task.id))) {
500
+ continue;
501
+ }
502
+ lanes.splice(lanes.indexOf(task), 1);
503
+ session.taskId = task.taskId;
504
+ if (task.status === 'blocked' || task.status === 'review' || task.status === 'pending') {
505
+ session.status = task.status;
506
+ }
507
+ markers.forEach((marker, index) => {
508
+ if (marker.laneId === task.id) {
509
+ markers[index] = { ...marker, laneId: session.id };
510
+ }
511
+ });
512
+ }
513
+
514
+ const times = [
515
+ ...lanes.flatMap((lane) => [lane.start, lane.end]).filter((time): time is number => time !== undefined),
516
+ ...markers.map((marker) => marker.timestamp),
517
+ ];
518
+ // The axis reaches `now` for a run still in flight; one whose last event is long past is idle or
519
+ // dead whatever its status says, and stretching the axis to now would bunch its nodes into a sliver.
520
+ const hasOpen = lanes.some((lane) => lane.start !== undefined && lane.end === undefined);
521
+ const latest = times.length > 0 ? Math.max(...times) : undefined;
522
+ if (hasOpen && now !== undefined && (latest === undefined || now - latest <= OPEN_LANE_STALE_MS)) {
523
+ times.push(now);
524
+ }
525
+ const start = times.length > 0 ? Math.min(...times) : (now ?? 0);
526
+ const end = times.length > 0 ? Math.max(...times) : start;
527
+
528
+ return { lanes, markers, range: { start, end } };
529
+ };
530
+
531
+ const taskLaneStatus = (task: Task.Task, tasks: readonly Task.Task[]): LaneStatus => {
532
+ if (task.status === 'todo' && !Task.isTaskReady(tasks, task)) {
533
+ return 'blocked';
534
+ }
535
+ return (task.status && TASK_STATUS[task.status]) ?? 'pending';
536
+ };
537
+
538
+ const subSessionStatus = (endEvent: Trace.FlatEvent | undefined, process: Process.Info | undefined): LaneStatus => {
539
+ if (endEvent) {
540
+ return decode(Trace.OperationEnd.schema, endEvent.data)?.outcome === 'failure' ? 'failed' : 'done';
541
+ }
542
+ if (process && !ACTIVE_STATES.has(process.state)) {
543
+ return process.state === Process.State.FAILED ? 'failed' : 'done';
544
+ }
545
+ return 'running';
546
+ };
547
+
548
+ const toMarker = (event: Trace.FlatEvent, id: string, laneId: string): Marker | undefined => {
549
+ const base = { id, laneId, timestamp: event.timestamp, pid: event.meta.pid };
550
+ switch (event.type) {
551
+ case Trace.AgentRequestBegin.key:
552
+ return { ...base, kind: 'request', label: 'Request started' };
553
+ case Trace.AgentRequestEnd.key: {
554
+ const data = decode(Trace.AgentRequestEnd.schema, event.data);
555
+ return {
556
+ ...base,
557
+ kind: 'request',
558
+ label: `Request ${data?.status ?? 'ended'}`,
559
+ level: data?.status === 'error' ? 'error' : data?.status === 'interrupted' ? 'warn' : undefined,
560
+ detail: data?.error,
561
+ };
562
+ }
563
+ case Trace.TaskStatusChanged.key: {
564
+ const data = decode(Trace.TaskStatusChanged.schema, event.data);
565
+ return {
566
+ ...base,
567
+ kind: 'task',
568
+ label: data?.status === 'started' ? 'Task started' : `Task ${data?.status ?? 'updated'}`,
569
+ level: data?.status === 'failed' ? 'error' : undefined,
570
+ detail: data,
571
+ };
572
+ }
573
+ case Trace.DelegationSpawned.key: {
574
+ const data = decode(Trace.DelegationSpawned.schema, event.data);
575
+ return { ...base, kind: 'delegation', label: 'Delegated', detail: data };
576
+ }
577
+ case Trace.OperationStart.key: {
578
+ const data = decode(Trace.OperationStart.schema, event.data);
579
+ return { ...base, kind: 'operation', label: data?.name ?? data?.key ?? 'Operation' };
580
+ }
581
+ case Trace.OperationEnd.key: {
582
+ const data = decode(Trace.OperationEnd.schema, event.data);
583
+ const failed = data?.outcome === 'failure';
584
+ return {
585
+ ...base,
586
+ kind: 'operation',
587
+ label: data?.name ?? data?.key ?? 'Operation',
588
+ level: failed ? 'error' : undefined,
589
+ detail: failed ? data?.error : undefined,
590
+ };
591
+ }
592
+ case Trace.CompleteBlock.key: {
593
+ const data = decode(Trace.CompleteBlock.schema, event.data);
594
+ if (data?.block._tag === 'toolCall') {
595
+ return { ...base, kind: 'tool', label: data.block.name };
596
+ }
597
+ if (data?.block._tag === 'text' && data.role === 'user') {
598
+ return { ...base, kind: 'message', label: data.block.text.split('\n')[0] ?? '' };
599
+ }
600
+ return undefined;
601
+ }
602
+ default:
603
+ return undefined;
604
+ }
605
+ };