@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,1076 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Array from 'effect/Array';
6
+ import { pipe } from 'effect/Function';
7
+ import * as Option from 'effect/Option';
8
+ import * as Pipeable from 'effect/Pipeable';
9
+ import * as Schema from 'effect/Schema';
10
+ import * as Struct from 'effect/Struct';
11
+
12
+ import { RUN_AGAIN_ERROR_CODE, RUN_AGAIN_MESSAGE } from '@dxos/compute';
13
+ import * as Process from '@dxos/compute/Process';
14
+ import * as Trace from '@dxos/compute/Trace';
15
+ import { Annotation } from '@dxos/echo';
16
+ import { EID } from '@dxos/keys';
17
+ import { LogLevel, log } from '@dxos/log';
18
+ import { type Commit } from '../components/Timeline/index.ts';
19
+ import { type ContentBlock } from '@dxos/types';
20
+
21
+ import { ROOT_SPAN_ID, type Span, buildSpanTree, isSpanBeginEvent, isSpanEndEvent, walkSpanTree } from './span-tree.ts';
22
+
23
+ /**
24
+ * Branch name for top-level operation invocations.
25
+ */
26
+ const MAIN_BRANCH = 'main';
27
+
28
+ /** Timeline tag that applies a shimmer message effect. */
29
+ const SHIMMER_EFFECT_TAG = 'effect:shimmer';
30
+
31
+ const ICONS = {
32
+ agentRequestBegin: {
33
+ icon: 'ph--atom--regular',
34
+ level: LogLevel.VERBOSE,
35
+ },
36
+ agentRequestEndSuccess: {
37
+ icon: 'ph--atom--regular',
38
+ level: LogLevel.INFO,
39
+ },
40
+ agentRequestEndError: {
41
+ icon: 'ph--atom--regular',
42
+ level: LogLevel.ERROR,
43
+ },
44
+ agentRequestEndInterrupted: {
45
+ icon: 'ph--atom--regular',
46
+ level: LogLevel.WARN,
47
+ },
48
+ userMessage: {
49
+ icon: 'ph--user--regular',
50
+ level: LogLevel.VERBOSE,
51
+ },
52
+ statusMessage: {
53
+ icon: 'ph--info--regular',
54
+ level: LogLevel.VERBOSE,
55
+ },
56
+ operationStart: {
57
+ icon: 'ph--function--regular',
58
+ level: LogLevel.VERBOSE,
59
+ },
60
+ operationEndSuccess: {
61
+ icon: 'ph--function--regular',
62
+ level: LogLevel.INFO,
63
+ },
64
+ operationEndError: {
65
+ icon: 'ph--function--regular',
66
+ level: LogLevel.ERROR,
67
+ },
68
+ operationEndIncomplete: {
69
+ icon: 'ph--arrows-clockwise--regular',
70
+ level: LogLevel.WARN,
71
+ },
72
+ toolCall: {
73
+ icon: 'ph--wrench--regular',
74
+ level: LogLevel.VERBOSE,
75
+ },
76
+ toolResultSuccess: {
77
+ icon: 'ph--wrench--regular',
78
+ level: LogLevel.INFO,
79
+ },
80
+ toolResultError: {
81
+ icon: 'ph--wrench--regular',
82
+ level: LogLevel.ERROR,
83
+ },
84
+ agentRequestRunning: {
85
+ icon: 'ph--spinner-gap--regular',
86
+ level: LogLevel.VERBOSE,
87
+ },
88
+ processRunning: {
89
+ icon: 'ph--spinner-gap--regular',
90
+ level: LogLevel.VERBOSE,
91
+ },
92
+ } as const;
93
+
94
+ export interface BuildExecutionGraphParams {
95
+ traceMessages: readonly Trace.Message[];
96
+ activeProcesses?: readonly Process.Info[];
97
+ collapseCompletedSpans?: boolean;
98
+ eventLimit?: number;
99
+ /**
100
+ * Duration a span may sit open with no new events before it's force-closed with a synthetic
101
+ * end event. Only takes effect when `now` is also provided. Defaults to
102
+ * `DEFAULT_SPAN_TIMEOUT_MS`.
103
+ */
104
+ spanTimeoutMs?: number;
105
+ /**
106
+ * Reference time used to detect abandoned spans. Force-closing is skipped entirely when this
107
+ * is omitted.
108
+ */
109
+ now?: number;
110
+ }
111
+
112
+ /**
113
+ * Builds a Timeline-compatible execution graph from trace messages and active processes.
114
+ *
115
+ * The conversion runs in two stages:
116
+ * 1. `buildSpanTree` converts the flat list of trace messages into a hierarchical
117
+ * tree of spans grouped by process id (pid). See `./span-tree.ts`.
118
+ * 2. `spanTreeToCommits` walks the tree and emits one commit per event, using the
119
+ * tree structure to decide which branch each commit belongs to and how to wire parents.
120
+ *
121
+ * Pure function — no signals or atoms.
122
+ */
123
+ export type ExecutionGraph = {
124
+ branches: string[];
125
+ commits: Commit[];
126
+ spanTree: Span;
127
+ /** Commit id -> trace event. */
128
+ details: ExecutionGraphDetailsMap;
129
+ };
130
+
131
+ export type ExecutionGraphDetailsMap = Record<string, Trace.FlatEvent | undefined>;
132
+
133
+ export const buildExecutionGraph = ({
134
+ traceMessages,
135
+ activeProcesses = [],
136
+ collapseCompletedSpans = false,
137
+ eventLimit = 500,
138
+ spanTimeoutMs,
139
+ now,
140
+ }: BuildExecutionGraphParams): ExecutionGraph => {
141
+ const spanTree = buildSpanTree(traceMessages, { eventLimit, spanTimeoutMs, now });
142
+ const toolCallContext = buildToolCallContext(traceMessages);
143
+ const built = spanTreeToCommits(spanTree, activeProcesses, toolCallContext, collapseCompletedSpans);
144
+ log('trace execution graph', {
145
+ traceMessages: traceMessages.length,
146
+ commits: built.commits.length,
147
+ branches: built.branches.length,
148
+ activeProcesses: activeProcesses.length,
149
+ });
150
+ return { ...built, spanTree };
151
+ };
152
+
153
+ /**
154
+ * Lookup tables that pair `toolCall` blocks with their matching `toolResult` blocks so the
155
+ * renderer can collapse the two events into a single commit (mirroring how begin/end events of
156
+ * an empty operation span collapse). Indexed by the shared `toolCallId`.
157
+ */
158
+ interface ToolCallContext {
159
+ callById: Map<string, ContentBlock.ToolCall>;
160
+ resultByCallId: Map<string, ContentBlock.ToolResult>;
161
+ }
162
+
163
+ const buildToolCallContext = (messages: readonly Trace.Message[]): ToolCallContext => {
164
+ const callById = new Map<string, ContentBlock.ToolCall>();
165
+ const resultByCallId = new Map<string, ContentBlock.ToolResult>();
166
+ for (const message of messages) {
167
+ for (const event of message.events) {
168
+ if (event.type !== Trace.CompleteBlock.key) {
169
+ continue;
170
+ }
171
+ const data = event.data as { block?: ContentBlock.Any } | undefined;
172
+ const block = data?.block;
173
+ if (!block) {
174
+ continue;
175
+ }
176
+ if (block._tag === 'toolCall') {
177
+ callById.set(block.toolCallId, block);
178
+ } else if (block._tag === 'toolResult') {
179
+ resultByCallId.set(block.toolCallId, block);
180
+ }
181
+ }
182
+ }
183
+ return { callById, resultByCallId };
184
+ };
185
+
186
+ /**
187
+ * Visual representation of a single trace event — icon, log level, message string.
188
+ * `undefined` indicates that the event should not produce a commit.
189
+ */
190
+ interface EventPresentation {
191
+ icon: string;
192
+ level: LogLevel;
193
+ message: string;
194
+ idSuffix?: string;
195
+ }
196
+
197
+ type AgentRequestEndData = Schema.Schema.Type<typeof Trace.AgentRequestEnd.schema>;
198
+
199
+ /**
200
+ * Parses `Trace.AgentRequestEnd` payload, accepting legacy traces that omitted `status`.
201
+ */
202
+ const parseAgentRequestEnd = (data: unknown): AgentRequestEndData | undefined => {
203
+ if (Schema.is(Trace.AgentRequestEnd.schema)(data)) {
204
+ return data;
205
+ }
206
+ // Traces emitted before `status` was added wrote an empty object.
207
+ if (data != null && typeof data === 'object' && Object.keys(data).length === 0) {
208
+ return { status: 'success' };
209
+ }
210
+ log('invalid trace event', { type: Trace.AgentRequestEnd.key });
211
+ return undefined;
212
+ };
213
+
214
+ const presentAgentRequestEnd = (data: AgentRequestEndData): EventPresentation => {
215
+ switch (data.status) {
216
+ case 'error':
217
+ return {
218
+ icon: ICONS.agentRequestEndError.icon,
219
+ level: ICONS.agentRequestEndError.level,
220
+ message: data.error ? trimText(`Agent request failed: ${data.error}`) : 'Agent request failed',
221
+ };
222
+ case 'interrupted':
223
+ return {
224
+ icon: ICONS.agentRequestEndInterrupted.icon,
225
+ level: ICONS.agentRequestEndInterrupted.level,
226
+ message: 'Agent request interrupted',
227
+ };
228
+ default:
229
+ return {
230
+ icon: ICONS.agentRequestEndSuccess.icon,
231
+ level: ICONS.agentRequestEndSuccess.level,
232
+ message: 'Agent completed request',
233
+ };
234
+ }
235
+ };
236
+
237
+ const presentEvent = (event: Trace.FlatEvent, toolCallContext: ToolCallContext): EventPresentation | undefined => {
238
+ if (Trace.isOfType(Trace.AgentRequestBegin, event)) {
239
+ if (!Schema.is(Trace.AgentRequestBegin.schema)(event.data)) {
240
+ log('invalid trace event', { type: event.type });
241
+ return undefined;
242
+ }
243
+ return {
244
+ icon: ICONS.agentRequestBegin.icon,
245
+ level: ICONS.agentRequestBegin.level,
246
+ message: 'Agent processing request...',
247
+ };
248
+ }
249
+ if (Trace.isOfType(Trace.AgentRequestEnd, event)) {
250
+ const endData = parseAgentRequestEnd(event.data);
251
+ if (!endData) {
252
+ return undefined;
253
+ }
254
+ return presentAgentRequestEnd(endData);
255
+ }
256
+ if (Trace.isOfType(Trace.CompleteBlock, event)) {
257
+ if (!Schema.is(Trace.CompleteBlock.schema)(event.data)) {
258
+ log('invalid trace event', { type: event.type });
259
+ return undefined;
260
+ }
261
+ switch (event.data.block._tag) {
262
+ case 'text':
263
+ return event.data.role === 'user'
264
+ ? {
265
+ icon: ICONS.userMessage.icon,
266
+ level: ICONS.userMessage.level,
267
+ message: trimText(event.data.block.text),
268
+ }
269
+ : undefined;
270
+ case 'status':
271
+ return {
272
+ icon: ICONS.statusMessage.icon,
273
+ level: ICONS.statusMessage.level,
274
+ message: trimText(event.data.block.statusText),
275
+ };
276
+ case 'toolCall': {
277
+ // Operation-backed tool calls already have a dedicated routine span (Trace.OperationStart /
278
+ // Trace.OperationEnd) that produces its own commits — skip the duplicate toolCall commit
279
+ // to avoid double-rendering.
280
+ if (event.data.block.operationKey) {
281
+ return undefined;
282
+ }
283
+ // Collapse the toolCall/toolResult pair: when the matching toolResult has arrived, the
284
+ // result event is the visible commit and carries the success/error state. The toolCall
285
+ // itself only renders while still pending (i.e. no result yet) — same intent as an
286
+ // operation span that collapses to a single end commit once both boundaries are present.
287
+ if (toolCallContext.resultByCallId.has(event.data.block.toolCallId)) {
288
+ return undefined;
289
+ }
290
+ return {
291
+ icon: ICONS.toolCall.icon,
292
+ level: ICONS.toolCall.level,
293
+ message: event.data.block.name,
294
+ idSuffix: `toolCall:${event.data.block.toolCallId}`,
295
+ };
296
+ }
297
+ case 'toolResult': {
298
+ // Suppress results for operation-backed calls — those are covered by the routine span.
299
+ const call = toolCallContext.callById.get(event.data.block.toolCallId);
300
+ if (call?.operationKey) {
301
+ return undefined;
302
+ }
303
+ const success = event.data.block.error === undefined;
304
+ const name = call?.name ?? event.data.block.name;
305
+ return {
306
+ icon: success ? ICONS.toolResultSuccess.icon : ICONS.toolResultError.icon,
307
+ level: success ? ICONS.toolResultSuccess.level : ICONS.toolResultError.level,
308
+ message: `${name}${!success ? ' - Error' : ''}`,
309
+ idSuffix: `toolResult:${event.data.block.toolCallId}`,
310
+ };
311
+ }
312
+ default:
313
+ return undefined;
314
+ }
315
+ }
316
+ if (Trace.isOfType(Trace.OperationStart, event)) {
317
+ if (!Schema.is(Trace.OperationStart.schema)(event.data)) {
318
+ log('invalid trace event', { type: event.type });
319
+ return undefined;
320
+ }
321
+ return {
322
+ icon: event.data.icon ?? ICONS.operationStart.icon,
323
+ level: ICONS.operationStart.level,
324
+ message: event.data.name ?? event.data.key,
325
+ idSuffix: `${event.data.key}:start`,
326
+ };
327
+ }
328
+ if (Trace.isOfType(Trace.OperationEnd, event)) {
329
+ if (!Schema.is(Trace.OperationEnd.schema)(event.data)) {
330
+ log('invalid trace event', { type: event.type });
331
+ return undefined;
332
+ }
333
+ // A `RunAgainError` (Operation.runAgain) is a scheduler yield, not a hard error: the trace records
334
+ // it as a failure tagged with the run-again error code, but it will be re-invoked. Detect it here
335
+ // and present it as a distinct warn-level state rather than folding it into failure. Fall back to
336
+ // the legacy message for events persisted before `errorCode` existed.
337
+ const incomplete =
338
+ event.data.outcome === 'failure' &&
339
+ (event.data.errorCode === RUN_AGAIN_ERROR_CODE ||
340
+ (event.data.errorCode === undefined && event.data.error === RUN_AGAIN_MESSAGE));
341
+ const presentation =
342
+ event.data.outcome === 'success'
343
+ ? { icon: ICONS.operationEndSuccess.icon, level: ICONS.operationEndSuccess.level, suffix: '' }
344
+ : incomplete
345
+ ? {
346
+ icon: ICONS.operationEndIncomplete.icon,
347
+ level: ICONS.operationEndIncomplete.level,
348
+ suffix: ' - Incomplete',
349
+ }
350
+ : { icon: ICONS.operationEndError.icon, level: ICONS.operationEndError.level, suffix: ' - Error' };
351
+ return {
352
+ icon: event.data.icon ?? presentation.icon,
353
+ level: presentation.level,
354
+ message: `${event.data.name ?? event.data.key}${presentation.suffix}`,
355
+ idSuffix: `${event.data.key}:end`,
356
+ };
357
+ }
358
+ return undefined;
359
+ };
360
+
361
+ export type CollectProcessActivityOptions = {
362
+ /**
363
+ * When set, messages with a `conversationId` that disagrees are excluded.
364
+ * Messages without `conversationId` still match when their pid is in the subtree.
365
+ */
366
+ conversationId?: string;
367
+ eventLimit?: number;
368
+ };
369
+
370
+ /**
371
+ * Collects human-readable activity lines for a process subtree (root pid and descendants).
372
+ * Used to surface live sub-agent status in the task list.
373
+ */
374
+ export const collectProcessActivityLines = (
375
+ messages: readonly Trace.Message[],
376
+ rootPid: string,
377
+ options: CollectProcessActivityOptions = {},
378
+ ): string[] => {
379
+ const { conversationId, eventLimit = 50 } = options;
380
+ const descendantPids = collectDescendantPids(messages, rootPid);
381
+ const filtered = messages.filter((message) => {
382
+ const pid = message.meta.pid;
383
+ if (!pid || !descendantPids.has(pid)) {
384
+ return false;
385
+ }
386
+ const metaConvId = message.meta.conversation
387
+ ? EID.getEntityId(EID.parse(message.meta.conversation.uri))
388
+ : undefined;
389
+ if (conversationId && metaConvId !== undefined && metaConvId !== conversationId) {
390
+ return false;
391
+ }
392
+ return true;
393
+ });
394
+
395
+ const events = filtered
396
+ .flatMap((message) => Trace.flatten(message))
397
+ .sort((left, right) => left.timestamp - right.timestamp)
398
+ .slice(-eventLimit);
399
+
400
+ const toolCallContext = buildToolCallContext(filtered);
401
+ const lines: string[] = [];
402
+ for (const event of events) {
403
+ const presentation = presentEvent(event, toolCallContext);
404
+ if (presentation) {
405
+ lines.push(presentation.message);
406
+ }
407
+ }
408
+ return lines;
409
+ };
410
+
411
+ /**
412
+ * Derives a single in-flight status line from durable trace when ephemeral subscription
413
+ * is unavailable (e.g. between partial blocks or while a child operation is running).
414
+ */
415
+ export const deriveInFlightActivityLine = (
416
+ messages: readonly Trace.Message[],
417
+ rootPid: string,
418
+ options: CollectProcessActivityOptions = {},
419
+ ): string | undefined => {
420
+ const { conversationId, eventLimit = 50 } = options;
421
+ const descendantPids = collectDescendantPids(messages, rootPid);
422
+ const filtered = messages.filter((message) => {
423
+ const pid = message.meta.pid;
424
+ if (!pid || !descendantPids.has(pid)) {
425
+ return false;
426
+ }
427
+ const metaConvId = message.meta.conversation
428
+ ? EID.getEntityId(EID.parse(message.meta.conversation.uri))
429
+ : undefined;
430
+ if (conversationId && metaConvId !== undefined && metaConvId !== conversationId) {
431
+ return false;
432
+ }
433
+ return true;
434
+ });
435
+
436
+ const events = filtered
437
+ .flatMap((message) => Trace.flatten(message))
438
+ .sort((left, right) => left.timestamp - right.timestamp)
439
+ .slice(-eventLimit);
440
+
441
+ const openOperations = new Map<string, { name: string; timestamp: number }>();
442
+ let agentRequestOpen = false;
443
+ let lastLine: string | undefined;
444
+
445
+ for (const event of events) {
446
+ const pid = event.meta.pid ?? rootPid;
447
+ if (Trace.isOfType(Trace.OperationStart, event)) {
448
+ const key = `${pid}:${event.data.key}`;
449
+ const name = event.data.name ?? event.data.key;
450
+ openOperations.set(key, { name, timestamp: event.timestamp });
451
+ lastLine = `${name}...`;
452
+ continue;
453
+ }
454
+ if (Trace.isOfType(Trace.OperationEnd, event)) {
455
+ openOperations.delete(`${pid}:${event.data.key}`);
456
+ continue;
457
+ }
458
+ if (Trace.isOfType(Trace.AgentRequestBegin, event)) {
459
+ agentRequestOpen = true;
460
+ lastLine = 'Generating...';
461
+ continue;
462
+ }
463
+ if (Trace.isOfType(Trace.AgentRequestEnd, event)) {
464
+ agentRequestOpen = false;
465
+ }
466
+ }
467
+
468
+ if (agentRequestOpen) {
469
+ return 'Generating...';
470
+ }
471
+
472
+ const latestOperation = [...openOperations.values()].sort((left, right) => right.timestamp - left.timestamp)[0];
473
+ if (latestOperation) {
474
+ return `${latestOperation.name}...`;
475
+ }
476
+
477
+ return lastLine;
478
+ };
479
+
480
+ const collectDescendantPids = (messages: readonly Trace.Message[], rootPid: string): Set<string> => {
481
+ const pids = new Set([rootPid]);
482
+ let expanded = true;
483
+ while (expanded) {
484
+ expanded = false;
485
+ for (const message of messages) {
486
+ const pid = message.meta.pid;
487
+ const parentPid = message.meta.parentPid;
488
+ if (pid && parentPid && pids.has(parentPid) && !pids.has(pid)) {
489
+ pids.add(pid);
490
+ expanded = true;
491
+ }
492
+ }
493
+ }
494
+ return pids;
495
+ };
496
+
497
+ const isPendingToolCallEvent = (event: Trace.FlatEvent, toolCallContext: ToolCallContext): boolean => {
498
+ if (!Trace.isOfType(Trace.CompleteBlock, event)) {
499
+ return false;
500
+ }
501
+ if (event.data.block._tag !== 'toolCall') {
502
+ return false;
503
+ }
504
+ return !toolCallContext.resultByCallId.has(event.data.block.toolCallId);
505
+ };
506
+
507
+ /**
508
+ * A span is completed when its last recorded event is a matching end boundary.
509
+ */
510
+ const isCompletedSpan = (span: Span): boolean => {
511
+ if (span.id === ROOT_SPAN_ID) {
512
+ return false;
513
+ }
514
+ const lastEvent = span.events[span.events.length - 1];
515
+ return lastEvent !== undefined && isSpanEndEvent(lastEvent);
516
+ };
517
+
518
+ /**
519
+ * A span is "collapsible" when it would otherwise render as an empty fork-and-merge:
520
+ * exactly two events that form a begin/end pair and no child sub-spans. In that case
521
+ * we emit a single commit for the end event on the parent branch.
522
+ *
523
+ * When `collapseCompletedSpans` is enabled, any completed span collapses to that single
524
+ * end commit — middle events, child sub-spans, and the begin fork are all omitted.
525
+ *
526
+ * A *pending* span (one whose end event has not yet been recorded) is never collapsed:
527
+ * its trailing events should keep rendering as middle commits on the span's own branch
528
+ * — never as a fake "end" commit on the parent branch.
529
+ *
530
+ * Both nested spans and top-level spans (whose parent is the synthetic root) are eligible
531
+ * once they have completed: a span with no inner activity is collapsed regardless of where
532
+ * it sits in the tree.
533
+ */
534
+ const isCollapsibleSpan = (span: Span, parent: Span | null, collapseCompletedSpans: boolean): boolean => {
535
+ if (span.id === ROOT_SPAN_ID) {
536
+ return false;
537
+ }
538
+ if (parent === null) {
539
+ return false;
540
+ }
541
+ if (span.events.length === 2 && span.children.length === 0) {
542
+ const firstEvent = span.events[0];
543
+ const lastEvent = span.events[span.events.length - 1];
544
+ if (isSpanBeginEvent(firstEvent) && isSpanEndEvent(lastEvent)) {
545
+ return true;
546
+ }
547
+ }
548
+ if (collapseCompletedSpans && isCompletedSpan(span)) {
549
+ return true;
550
+ }
551
+ return false;
552
+ };
553
+
554
+ /**
555
+ * Converts a span tree into a flat list of commits + branches suitable for `Timeline` rendering.
556
+ *
557
+ * For every span the algorithm:
558
+ * - emits the **first** event as a "begin" commit on the parent span's branch (fork);
559
+ * - emits **middle** events as commits on the span's own branch;
560
+ * - emits the **last** event as an "end" commit on the parent span's branch with two parents
561
+ * (the begin commit and the last commit on the span's own branch — a merge).
562
+ *
563
+ * Collapsible spans (`isCollapsibleSpan`) emit only the end commit on the parent branch with
564
+ * a single parent — a tidier rendering for sub-operations that have no inner detail.
565
+ *
566
+ * Events are processed in categorical span-tree order: each span's begin events, its child
567
+ * subtrees (depth-first), then its remaining events. Active-process running rows are inserted
568
+ * at the end of each matching span block — not appended after the entire tree.
569
+ */
570
+ const spanTreeToCommits = (
571
+ root: Span,
572
+ activeProcesses: readonly Process.Info[],
573
+ toolCallContext: ToolCallContext,
574
+ collapseCompletedSpans: boolean,
575
+ ): { branches: string[]; commits: Commit[]; details: ExecutionGraphDetailsMap } => {
576
+ const builder = new GraphBuilder();
577
+ const details: ExecutionGraphDetailsMap = {};
578
+ builder.addBranch(MAIN_BRANCH);
579
+
580
+ // Build a child → parent lookup using the tree's own structure (already correctly parented
581
+ // when the tree was constructed, including sequential spans of the same process).
582
+ const parentBySpan = new Map<Span, Span | null>();
583
+ parentBySpan.set(root, null);
584
+ const indexParents = (span: Span): void => {
585
+ for (const child of span.children) {
586
+ parentBySpan.set(child, span);
587
+ indexParents(child);
588
+ }
589
+ };
590
+ indexParents(root);
591
+
592
+ const findParentSpan = (span: Span): Span | null => parentBySpan.get(span) ?? null;
593
+
594
+ const collapsedSpanIds = new Set<string>();
595
+ walkSpanTree(root, (span) => {
596
+ if (isCollapsibleSpan(span, findParentSpan(span), collapseCompletedSpans)) {
597
+ collapsedSpanIds.add(span.id);
598
+ }
599
+ });
600
+
601
+ const isUnderCollapsedAncestor = (span: Span): boolean => {
602
+ let ancestor = findParentSpan(span);
603
+ while (ancestor) {
604
+ if (collapsedSpanIds.has(ancestor.id)) {
605
+ return true;
606
+ }
607
+ ancestor = findParentSpan(ancestor);
608
+ }
609
+ return false;
610
+ };
611
+
612
+ // The branch name for a span is its pid — multiple sequential spans of the same process
613
+ // (e.g. successive agent requests in one session) share a branch so they reuse a single lane.
614
+ const branchOf = (span: Span | null): string => {
615
+ if (!span || span.id === ROOT_SPAN_ID) {
616
+ return MAIN_BRANCH;
617
+ }
618
+ return span.meta.pid ?? span.id;
619
+ };
620
+
621
+ // Track the id of the begin commit for each non-collapsible span so the end commit can merge into it.
622
+ const beginCommitIdBySpan = new Map<string, string>();
623
+
624
+ // Selector that returns "the most recent commit in this span's structural context".
625
+ //
626
+ // The walk is structural (pid/parentPid), not topological: at each ancestor, we prefer
627
+ // 1) the last commit on that span's own branch, then
628
+ // 2) that span's begin commit (the fork point on its parent branch),
629
+ // 3) and only then recurse into the parent span.
630
+ //
631
+ // Crucially, we never silently slip onto a *sibling* span's commit on a shared ancestor
632
+ // branch. E.g. if a sub-span of agent A fires before A has any middle commits, we anchor
633
+ // to A.begin — not to whatever unrelated commit happens to be the latest on main.
634
+ // This keeps fork edges visually attached to the correct span even when sibling top-level
635
+ // spans (different pids) overlap in time on main.
636
+ const lastInSpanContext = (span: Span | null): CommitSelector => {
637
+ if (!span || span.id === ROOT_SPAN_ID) {
638
+ return CommitSelector.branch(MAIN_BRANCH).pipe(CommitSelector.compose(CommitSelector.last()));
639
+ }
640
+ const ownBranch = branchOf(span);
641
+ const beginId = beginCommitIdBySpan.get(span.id);
642
+ return CommitSelector.firstOf(
643
+ CommitSelector.branch(ownBranch).pipe(CommitSelector.compose(CommitSelector.last())),
644
+ beginId ? CommitSelector.id(beginId) : CommitSelector.filter(() => false),
645
+ lastInSpanContext(findParentSpan(span)),
646
+ );
647
+ };
648
+
649
+ const lastCommitIdBySpan = new Map<string, string>();
650
+ let globalIndex = 0;
651
+ const insertedRunningPids = new Set<string>();
652
+ const activeProcessByPid = new Map(activeProcesses.map((process) => [process.pid, process]));
653
+
654
+ const emitRunningIndicator = (pid: Process.ID): void => {
655
+ if (insertedRunningPids.has(pid)) {
656
+ return;
657
+ }
658
+
659
+ const process = activeProcessByPid.get(pid);
660
+ if (!process) {
661
+ return;
662
+ }
663
+
664
+ if (
665
+ Process.isHarnessHost(process) &&
666
+ Option.isSome(Annotation.getDictionary(process.params.annotations, Process.TargetAnnotation)) &&
667
+ (process.state === Process.State.RUNNING || process.state === Process.State.HYBERNATING)
668
+ ) {
669
+ builder.addCommit({
670
+ id: `running:${process.pid}`,
671
+ branch: process.pid,
672
+ // Falls back to the tail of main: a completed request collapses onto main, leaving the
673
+ // agent's own branch empty, and a parentless spinner draws as a second, disconnected root.
674
+ parents: builder.computeParents(
675
+ CommitSelector.firstOf(
676
+ CommitSelector.branch(process.pid).pipe(CommitSelector.compose(CommitSelector.last())),
677
+ CommitSelector.branch(MAIN_BRANCH).pipe(CommitSelector.compose(CommitSelector.last())),
678
+ ),
679
+ ),
680
+ icon: ICONS.agentRequestRunning.icon,
681
+ level: ICONS.agentRequestRunning.level,
682
+ message: 'Generating...',
683
+ timestamp: new Date(),
684
+ tags: [SHIMMER_EFFECT_TAG],
685
+ });
686
+ insertedRunningPids.add(pid);
687
+ } else if (process.state === Process.State.RUNNING && builder.hasBranch(process.pid)) {
688
+ builder.addCommit({
689
+ id: `running:${process.pid}`,
690
+ branch: process.pid,
691
+ parents: builder.computeParents(
692
+ CommitSelector.branch(process.pid).pipe(CommitSelector.compose(CommitSelector.last())),
693
+ ),
694
+ icon: ICONS.processRunning.icon,
695
+ level: ICONS.processRunning.level,
696
+ message: 'Running...',
697
+ timestamp: new Date(),
698
+ tags: [SHIMMER_EFFECT_TAG],
699
+ });
700
+ insertedRunningPids.add(pid);
701
+ }
702
+ };
703
+
704
+ const emitEventCommit = (span: Span, event: Trace.FlatEvent, eventIndex: number): void => {
705
+ const presentation = presentEvent(event, toolCallContext);
706
+ if (!presentation) {
707
+ return;
708
+ }
709
+
710
+ // Classify based on event TYPE, not array index. A pending span's trailing
711
+ // middle event (e.g. a user message awaiting an agent response) must not be
712
+ // mistaken for the span's end event — otherwise it would render on the
713
+ // parent branch instead of the span's own branch.
714
+ const isBeginEvent = isSpanBeginEvent(event);
715
+ const isEndEvent = isSpanEndEvent(event);
716
+ const parentSpan = findParentSpan(span);
717
+ const collapsible = isCollapsibleSpan(span, parentSpan, collapseCompletedSpans);
718
+
719
+ // Descendants of a collapsed span emit no commits — inner activity is folded into the ancestor end row.
720
+ if (isUnderCollapsedAncestor(span)) {
721
+ return;
722
+ }
723
+
724
+ // Collapsed spans: skip every event except the end commit.
725
+ if (collapsible && !isEndEvent) {
726
+ return;
727
+ }
728
+
729
+ const parentBranch = branchOf(parentSpan);
730
+ const ownBranch = branchOf(span);
731
+
732
+ // A "process boundary" span is a concurrent child PROCESS (its own pid, differing from the
733
+ // parent's) with real sub-flow content (a middle event) — e.g. a delegated sub-agent. Such a
734
+ // span gets its own lane: it forks onto its own branch at begin and collapses back to main at
735
+ // end (so neither its lane nor its parent's lane dangles). A bare operation (begin+end only)
736
+ // collapses to a single commit and is NOT treated as a boundary — forking its transient begin
737
+ // mid-stream would flash a throwaway lane.
738
+ const ownPid = span.meta.pid;
739
+ const parentPid = parentSpan?.meta.pid;
740
+ const spanHasMiddle = span.events.some((spanEvent) => !isSpanBeginEvent(spanEvent) && !isSpanEndEvent(spanEvent));
741
+ const isProcessBoundary = ownPid != null && parentPid != null && ownPid !== parentPid && spanHasMiddle;
742
+
743
+ let branch: string;
744
+ let parents: string[];
745
+
746
+ if (span.id === ROOT_SPAN_ID) {
747
+ // Root-level events (no pid) attach sequentially to main.
748
+ branch = MAIN_BRANCH;
749
+ parents = builder.computeParents(lastInSpanContext(null));
750
+ } else if (collapsible) {
751
+ // Only the end event reaches here for collapsible spans.
752
+ // Anchor to the parent span's structural context so the fork attaches to the parent
753
+ // — not to a sibling span's commit that happens to be the latest on the shared ancestor.
754
+ branch = parentBranch;
755
+ parents = builder.computeParents(lastInSpanContext(parentSpan));
756
+ } else if (isBeginEvent) {
757
+ // Begin commit, anchored to the parent's structural context. A nested sub-span stays on the
758
+ // parent lane (only its middle events fork). A concurrent child process forks onto its own
759
+ // branch from this first event so it gets its own lane immediately.
760
+ branch = isProcessBoundary ? ownBranch : parentBranch;
761
+ parents = builder.computeParents(lastInSpanContext(parentSpan));
762
+ } else if (isEndEvent) {
763
+ // End commit: continues the parent branch chronologically and merges in own-branch work.
764
+ //
765
+ // Parent #1 is the *immediate predecessor* on the parent branch (not the matching begin
766
+ // commit). Pointing back to the begin commit creates a visual edge that skips over any
767
+ // sibling span's commits emitted between begin and end — e.g. when two top-level Routine
768
+ // spans overlap on main, R1.end would otherwise draw an arrow back to R1.begin that
769
+ // crosses over R2.begin. Anchoring to the previous main commit keeps main linear.
770
+ //
771
+ // A concurrent child process collapses all the way back to MAIN (merging its own branch),
772
+ // rather than to its parent's branch: the parent (e.g. a supervisor whose turn already merged
773
+ // to main) would otherwise gain commits after its own merge and never re-collapse — leaving a
774
+ // dangling lane after the child finishes.
775
+ branch = isProcessBoundary ? MAIN_BRANCH : parentBranch;
776
+ parents = builder.computeParents(
777
+ CommitSelector.unionAll(
778
+ CommitSelector.branch(branch).pipe(CommitSelector.compose(CommitSelector.last())),
779
+ CommitSelector.branch(ownBranch).pipe(CommitSelector.compose(CommitSelector.last())),
780
+ ),
781
+ );
782
+ } else {
783
+ // Middle event: continue the span's own branch; fall back to span's own context so the
784
+ // first middle event of a span forks from the span's begin commit, not from a sibling's
785
+ // commit on an ancestor branch.
786
+ branch = ownBranch;
787
+ parents = builder.computeParents(lastInSpanContext(span));
788
+ }
789
+
790
+ const commitIndex = globalIndex++;
791
+ const commitId = formatCommitId(span, commitIndex, presentation.idSuffix);
792
+ // A pending tool call still in flight gets Timeline shimmer styling.
793
+ const shimmer = isPendingToolCallEvent(event, toolCallContext);
794
+ const commit: Commit = {
795
+ id: commitId,
796
+ branch,
797
+ parents,
798
+ timestamp: new Date(event.timestamp),
799
+ icon: presentation.icon,
800
+ level: presentation.level,
801
+ message: presentation.message,
802
+ ...(shimmer && { tags: [SHIMMER_EFFECT_TAG] }),
803
+ };
804
+ builder.addCommit(commit);
805
+ details[commitId] = event;
806
+ lastCommitIdBySpan.set(span.id, commitId);
807
+
808
+ // Record begin commit so a later end event can merge into it.
809
+ if (isBeginEvent && !collapsible && span.id !== ROOT_SPAN_ID) {
810
+ beginCommitIdBySpan.set(span.id, commitId);
811
+ }
812
+ };
813
+
814
+ const finishSpan = (span: Span): void => {
815
+ const pid = span.meta.pid;
816
+ if (pid) {
817
+ // Trace.Meta.pid is plain string (avoids circular dep); construct brand here.
818
+ emitRunningIndicator(Process.ID.make(pid));
819
+ }
820
+
821
+ if (span.id === ROOT_SPAN_ID || isCompletedSpan(span)) {
822
+ return;
823
+ }
824
+
825
+ if (pid && insertedRunningPids.has(pid)) {
826
+ return;
827
+ }
828
+
829
+ const lastCommitId = lastCommitIdBySpan.get(span.id);
830
+ if (lastCommitId) {
831
+ builder.addTag(lastCommitId, SHIMMER_EFFECT_TAG);
832
+ }
833
+ };
834
+
835
+ const emitSpanCommits = (span: Span): void => {
836
+ for (let eventIndex = 0; eventIndex < span.events.length; eventIndex += 1) {
837
+ const event = span.events[eventIndex]!;
838
+ if (isSpanBeginEvent(event)) {
839
+ emitEventCommit(span, event, eventIndex);
840
+ }
841
+ }
842
+
843
+ for (const child of span.children) {
844
+ emitSpanCommits(child);
845
+ }
846
+
847
+ for (let eventIndex = 0; eventIndex < span.events.length; eventIndex += 1) {
848
+ const event = span.events[eventIndex]!;
849
+ if (!isSpanBeginEvent(event)) {
850
+ emitEventCommit(span, event, eventIndex);
851
+ }
852
+ }
853
+
854
+ finishSpan(span);
855
+ };
856
+
857
+ emitSpanCommits(root);
858
+
859
+ for (const process of activeProcesses) {
860
+ emitRunningIndicator(process.pid);
861
+ }
862
+
863
+ const { commits, branches } = builder.build();
864
+ return { commits, branches, details };
865
+ };
866
+
867
+ const formatCommitId = (span: Span, globalIndex: number, suffix?: string): string => {
868
+ const base = `${span.id}:${globalIndex}`;
869
+ return suffix ? `${base}:${suffix}` : base;
870
+ };
871
+
872
+ const trimText = (text: string) => text.slice(0, 100).trim().split('\n')[0];
873
+
874
+ type Falsy = false | null | undefined;
875
+
876
+ export interface CommitSelector extends Pipeable.Pipeable {
877
+ select: (commits: Commit[]) => Commit[];
878
+ }
879
+
880
+ export const CommitSelector = {
881
+ make: (select: CommitSelector['select']): CommitSelector => ({
882
+ select,
883
+ pipe(...args: any) {
884
+ return Pipeable.pipeArguments(this, args);
885
+ },
886
+ }),
887
+
888
+ identity: (): CommitSelector => CommitSelector.make((commits) => commits),
889
+
890
+ /**
891
+ * Selects commits that match the given predicate.
892
+ */
893
+ filter: (predicate: (commit: Commit) => unknown): CommitSelector =>
894
+ CommitSelector.make((commits) => commits.filter(predicate)),
895
+
896
+ /**
897
+ * Selects commits by id.
898
+ */
899
+ id: (id: string): CommitSelector => CommitSelector.filter((commit) => commit.id === id),
900
+ /**
901
+ * Selects commits by tag.
902
+ */
903
+ tag: (tag: string | Falsy): CommitSelector => CommitSelector.filter((commit) => tag && commit.tags?.includes(tag)),
904
+ /**
905
+ * Selects commits by if any of the given tags are present.
906
+ */
907
+ anyTags: (tags: (string | Falsy)[]): CommitSelector =>
908
+ CommitSelector.filter((commit) => tags.some((tag) => tag && commit.tags?.includes(tag))),
909
+ /**
910
+ * Selects commits by branch.
911
+ */
912
+ branch: (branch: string | Falsy): CommitSelector =>
913
+ CommitSelector.filter((commit) => branch && commit.branch === branch),
914
+
915
+ /**
916
+ * Chains two selectors together, applying the second selector to the result of the first.
917
+ *
918
+ * Example:
919
+ *
920
+ * ```
921
+ * CommitSelector.tag('tag').pipe(CommitSelector.compose(CommitSelector.branch('main')));
922
+ * ```
923
+ */
924
+ compose:
925
+ (next: CommitSelector) =>
926
+ (prev: CommitSelector): CommitSelector =>
927
+ CommitSelector.make((commits) => next.select(prev.select(commits))),
928
+
929
+ /**
930
+ * If `prev` selector matches no commits, return `next` selector, otherwise return `prev` selector.
931
+ * Same as `CommitSelector.firstOf(prev, next)`.
932
+ *
933
+ * Example:
934
+ *
935
+ * ```
936
+ * CommitSelector.tag('tag').pipe(CommitSelector.orElse(CommitSelector.branch('main')));
937
+ * ```
938
+ */
939
+ orElse:
940
+ (next: CommitSelector) =>
941
+ (prev: CommitSelector): CommitSelector =>
942
+ CommitSelector.make((commits) => {
943
+ const selected = prev.select(commits);
944
+ if (selected.length > 0) {
945
+ return selected;
946
+ }
947
+ return next.select(commits);
948
+ }),
949
+
950
+ /**
951
+ * Selects commits that match either of the given selectors.
952
+ */
953
+ andAlso:
954
+ (next: CommitSelector) =>
955
+ (prev: CommitSelector): CommitSelector =>
956
+ CommitSelector.unionAll(prev, next),
957
+
958
+ /**
959
+ * Selects the first n commits.
960
+ */
961
+ first: (n: number = 1): CommitSelector => CommitSelector.make((commits) => commits.slice(0, n)),
962
+ /**
963
+ * Selects the last n commits (reverses the order of the commits).
964
+ */
965
+ last: (n: number = 1): CommitSelector => CommitSelector.make((commits) => commits.toReversed().slice(0, n)),
966
+ /**
967
+ * Selects commits that do not match the given selector.
968
+ */
969
+ not: (selector: CommitSelector): CommitSelector =>
970
+ CommitSelector.make((commits) => {
971
+ const selected = selector.select(commits);
972
+ return commits.filter((commit) => !selected.includes(commit));
973
+ }),
974
+
975
+ /**
976
+ * Selects commits that match any of the given selectors.
977
+ */
978
+ unionAll: (...selectors: CommitSelector[]): CommitSelector =>
979
+ CommitSelector.make((commits) => {
980
+ return Array.dedupeWith(
981
+ selectors.flatMap((selector) => selector.select(commits)),
982
+ (a, b) => a.id === b.id,
983
+ );
984
+ }),
985
+
986
+ /**
987
+ * Selects commits that match all of the given selectors.
988
+ */
989
+ intersectAll: (...selectors: CommitSelector[]): CommitSelector =>
990
+ CommitSelector.make((commits) => {
991
+ const selected = selectors.map((selector) => selector.select(commits));
992
+ if (selected.length === 0) {
993
+ return [];
994
+ }
995
+ return selected[0].filter((commit) => selected.every((selected) => selected.some((c) => c.id === commit.id)));
996
+ }),
997
+
998
+ /**
999
+ * Returns the result of the first selector that matches any commits.
1000
+ */
1001
+ firstOf: (...selectors: CommitSelector[]): CommitSelector =>
1002
+ CommitSelector.make((commits) => {
1003
+ for (const selector of selectors) {
1004
+ const selected = selector.select(commits);
1005
+ if (selected.length > 0) {
1006
+ return selected;
1007
+ }
1008
+ }
1009
+ return [];
1010
+ }),
1011
+ };
1012
+
1013
+ class GraphBuilder {
1014
+ #commits: Commit[] = [];
1015
+ #branches = new Set<string>();
1016
+
1017
+ findCommits(selector: CommitSelector): Commit[] {
1018
+ return selector.select(this.#commits);
1019
+ }
1020
+
1021
+ hasBranch(branch: string): boolean {
1022
+ return this.#branches.has(branch);
1023
+ }
1024
+
1025
+ addCommit(commit: Commit) {
1026
+ this.addBranch(commit.branch);
1027
+ this.#commits.push(commit);
1028
+ }
1029
+
1030
+ addTag(commitId: string, tag: string) {
1031
+ const index = this.#commits.findIndex((commit) => commit.id === commitId);
1032
+ if (index < 0) {
1033
+ return;
1034
+ }
1035
+ const commit = this.#commits[index]!;
1036
+ if (commit.tags?.includes(tag)) {
1037
+ return;
1038
+ }
1039
+ this.#commits[index] = { ...commit, tags: [...(commit.tags ?? []), tag] };
1040
+ }
1041
+
1042
+ /**
1043
+ * Computes parents — picks first matching commit's id.
1044
+ */
1045
+ computeParents(selector: CommitSelector): string[] {
1046
+ return Array.dedupe(selector.select(this.#commits).map((commit) => commit.id));
1047
+ }
1048
+
1049
+ addBranch(branch: string) {
1050
+ this.#branches.add(branch);
1051
+ }
1052
+
1053
+ /**
1054
+ * Removes duplicate commits and dangling parents.
1055
+ */
1056
+ doctor() {
1057
+ this.#commits = pipe(
1058
+ this.#commits,
1059
+ Array.dedupeWith((a, b) => a.id === b.id),
1060
+ Array.map(
1061
+ Struct.evolve({
1062
+ parents: (parents) =>
1063
+ parents ? Array.filter(parents, (id) => this.#commits.some((commit) => commit.id === id)) : undefined,
1064
+ }),
1065
+ ),
1066
+ );
1067
+ }
1068
+
1069
+ build() {
1070
+ this.doctor();
1071
+ return {
1072
+ commits: this.#commits,
1073
+ branches: [...this.#branches],
1074
+ };
1075
+ }
1076
+ }