@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.
- package/LICENSE +105 -0
- package/README.md +3 -0
- package/dist/lib/index.mjs +2868 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/testing.mjs +338 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/lib/translations.mjs +19 -0
- package/dist/lib/translations.mjs.map +1 -0
- package/dist/types/src/components/Gantt/Gantt.d.ts +68 -0
- package/dist/types/src/components/Gantt/Gantt.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/Gantt.stories.d.ts +30 -0
- package/dist/types/src/components/Gantt/Gantt.stories.d.ts.map +1 -0
- package/dist/types/src/components/Gantt/index.d.ts +2 -0
- package/dist/types/src/components/Gantt/index.d.ts.map +1 -0
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts +32 -0
- package/dist/types/src/components/ProcessTree/ProcessTree.d.ts.map +1 -0
- package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts +8 -0
- package/dist/types/src/components/ProcessTree/ProcessTree.stories.d.ts.map +1 -0
- package/dist/types/src/components/ProcessTree/index.d.ts +2 -0
- package/dist/types/src/components/ProcessTree/index.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/Timeline.d.ts +50 -0
- package/dist/types/src/components/Timeline/Timeline.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts +24 -0
- package/dist/types/src/components/Timeline/Timeline.stories.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/index.d.ts +4 -0
- package/dist/types/src/components/Timeline/index.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/timeline-options.d.ts +9 -0
- package/dist/types/src/components/Timeline/timeline-options.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/timeline-printer.d.ts +7 -0
- package/dist/types/src/components/Timeline/timeline-printer.d.ts.map +1 -0
- package/dist/types/src/components/Timeline/timeline-printer.test.d.ts +2 -0
- package/dist/types/src/components/Timeline/timeline-printer.test.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/TracePanel.d.ts +28 -0
- package/dist/types/src/components/TracePanel/TracePanel.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/dxn-extractor.d.ts +19 -0
- package/dist/types/src/components/TracePanel/dxn-extractor.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/dxn-extractor.test.d.ts +2 -0
- package/dist/types/src/components/TracePanel/dxn-extractor.test.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/index.d.ts +4 -0
- package/dist/types/src/components/TracePanel/index.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/trace-filter.d.ts +39 -0
- package/dist/types/src/components/TracePanel/trace-filter.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/trace-filter.test.d.ts +2 -0
- package/dist/types/src/components/TracePanel/trace-filter.test.d.ts.map +1 -0
- package/dist/types/src/components/TracePanel/useTraceMenu.d.ts +15 -0
- package/dist/types/src/components/TracePanel/useTraceMenu.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +5 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/execution-graph/execution-graph.d.ts +139 -0
- package/dist/types/src/execution-graph/execution-graph.d.ts.map +1 -0
- package/dist/types/src/execution-graph/execution-graph.test.d.ts +2 -0
- package/dist/types/src/execution-graph/execution-graph.test.d.ts.map +1 -0
- package/dist/types/src/execution-graph/index.d.ts +4 -0
- package/dist/types/src/execution-graph/index.d.ts.map +1 -0
- package/dist/types/src/execution-graph/pending-block-status.d.ts +26 -0
- package/dist/types/src/execution-graph/pending-block-status.d.ts.map +1 -0
- package/dist/types/src/execution-graph/pending-block-status.test.d.ts +2 -0
- package/dist/types/src/execution-graph/pending-block-status.test.d.ts.map +1 -0
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts +2 -0
- package/dist/types/src/execution-graph/remote-snapshot.test.d.ts.map +1 -0
- package/dist/types/src/execution-graph/span-tree.d.ts +106 -0
- package/dist/types/src/execution-graph/span-tree.d.ts.map +1 -0
- package/dist/types/src/execution-graph/span-tree.test.d.ts +2 -0
- package/dist/types/src/execution-graph/span-tree.test.d.ts.map +1 -0
- package/dist/types/src/execution-graph/sub-agent-delegation.test.d.ts +2 -0
- package/dist/types/src/execution-graph/sub-agent-delegation.test.d.ts.map +1 -0
- package/dist/types/src/execution-graph/testing/collect-trace-events.d.ts +40 -0
- package/dist/types/src/execution-graph/testing/collect-trace-events.d.ts.map +1 -0
- package/dist/types/src/execution-graph/testing/index.d.ts +2 -0
- package/dist/types/src/execution-graph/testing/index.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +4 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useExecutionGraph.d.ts +17 -0
- package/dist/types/src/hooks/useExecutionGraph.d.ts.map +1 -0
- package/dist/types/src/hooks/useSessionTimeline.d.ts +17 -0
- package/dist/types/src/hooks/useSessionTimeline.d.ts.map +1 -0
- package/dist/types/src/hooks/useTraceMessages.d.ts +17 -0
- package/dist/types/src/hooks/useTraceMessages.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +5 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/session-timeline/index.d.ts +3 -0
- package/dist/types/src/session-timeline/index.d.ts.map +1 -0
- package/dist/types/src/session-timeline/session-timeline.d.ts +19 -0
- package/dist/types/src/session-timeline/session-timeline.d.ts.map +1 -0
- package/dist/types/src/session-timeline/session-timeline.test.d.ts +2 -0
- package/dist/types/src/session-timeline/session-timeline.test.d.ts.map +1 -0
- package/dist/types/src/session-timeline/types.d.ts +129 -0
- package/dist/types/src/session-timeline/types.d.ts.map +1 -0
- package/dist/types/src/testing/index.d.ts +5 -0
- package/dist/types/src/testing/index.d.ts.map +1 -0
- package/dist/types/src/testing/simulated-agent.d.ts +63 -0
- package/dist/types/src/testing/simulated-agent.d.ts.map +1 -0
- package/dist/types/src/testing/snapshot-playback.d.ts +12 -0
- package/dist/types/src/testing/snapshot-playback.d.ts.map +1 -0
- package/dist/types/src/testing/test-trace.d.ts +5 -0
- package/dist/types/src/testing/test-trace.d.ts.map +1 -0
- package/dist/types/src/translations.d.ts +19 -0
- package/dist/types/src/translations.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +77 -0
- package/src/components/Gantt/Gantt.stories.tsx +241 -0
- package/src/components/Gantt/Gantt.tsx +589 -0
- package/src/components/Gantt/index.ts +5 -0
- package/src/components/ProcessTree/ProcessTree.stories.tsx +95 -0
- package/src/components/ProcessTree/ProcessTree.tsx +265 -0
- package/src/components/ProcessTree/index.ts +5 -0
- package/src/components/Timeline/Timeline.stories.tsx +326 -0
- package/src/components/Timeline/Timeline.tsx +534 -0
- package/src/components/Timeline/git.drawio.svg +821 -0
- package/src/components/Timeline/index.ts +7 -0
- package/src/components/Timeline/timeline-options.ts +27 -0
- package/src/components/Timeline/timeline-printer.test.ts +76 -0
- package/src/components/Timeline/timeline-printer.ts +223 -0
- package/src/components/TracePanel/TracePanel.tsx +248 -0
- package/src/components/TracePanel/dxn-extractor.test.ts +153 -0
- package/src/components/TracePanel/dxn-extractor.ts +166 -0
- package/src/components/TracePanel/index.ts +7 -0
- package/src/components/TracePanel/trace-filter.test.ts +137 -0
- package/src/components/TracePanel/trace-filter.ts +120 -0
- package/src/components/TracePanel/useTraceMenu.ts +95 -0
- package/src/components/index.ts +8 -0
- package/src/execution-graph/SPEC.md +749 -0
- package/src/execution-graph/execution-graph.test.ts +1205 -0
- package/src/execution-graph/execution-graph.ts +1076 -0
- package/src/execution-graph/index.ts +35 -0
- package/src/execution-graph/pending-block-status.test.ts +118 -0
- package/src/execution-graph/pending-block-status.ts +88 -0
- package/src/execution-graph/remote-snapshot.test.ts +116 -0
- package/src/execution-graph/span-tree.test.ts +370 -0
- package/src/execution-graph/span-tree.ts +404 -0
- package/src/execution-graph/sub-agent-delegation.test.ts +65 -0
- package/src/execution-graph/testing/collect-trace-events.ts +76 -0
- package/src/execution-graph/testing/index.ts +5 -0
- package/src/execution-graph/testing/sub-agent-delegation.json +1285 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/useExecutionGraph.ts +107 -0
- package/src/hooks/useSessionTimeline.ts +53 -0
- package/src/hooks/useTraceMessages.ts +58 -0
- package/src/index.ts +8 -0
- package/src/session-timeline/index.ts +6 -0
- package/src/session-timeline/session-timeline.test.ts +662 -0
- package/src/session-timeline/session-timeline.ts +605 -0
- package/src/session-timeline/types.ts +100 -0
- package/src/testing/index.ts +8 -0
- package/src/testing/simulated-agent.ts +293 -0
- package/src/testing/snapshot-playback.ts +45 -0
- package/src/testing/test-trace.ts +22 -0
- 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 })));
|