stemtrace 0.1.0__py3-none-any.whl

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 (72) hide show
  1. stemtrace/__init__.py +173 -0
  2. stemtrace/core/__init__.py +15 -0
  3. stemtrace/core/events.py +67 -0
  4. stemtrace/core/exceptions.py +25 -0
  5. stemtrace/core/graph.py +348 -0
  6. stemtrace/core/ports.py +42 -0
  7. stemtrace/library/__init__.py +1 -0
  8. stemtrace/library/bootsteps.py +169 -0
  9. stemtrace/library/config.py +55 -0
  10. stemtrace/library/scrubbing.py +195 -0
  11. stemtrace/library/signals.py +434 -0
  12. stemtrace/library/transports/__init__.py +36 -0
  13. stemtrace/library/transports/memory.py +33 -0
  14. stemtrace/library/transports/redis.py +92 -0
  15. stemtrace/py.typed +0 -0
  16. stemtrace/server/__init__.py +24 -0
  17. stemtrace/server/__main__.py +136 -0
  18. stemtrace/server/api/__init__.py +29 -0
  19. stemtrace/server/api/routes.py +270 -0
  20. stemtrace/server/api/schemas.py +136 -0
  21. stemtrace/server/api/websocket.py +28 -0
  22. stemtrace/server/consumer.py +133 -0
  23. stemtrace/server/fastapi/__init__.py +13 -0
  24. stemtrace/server/fastapi/auth.py +77 -0
  25. stemtrace/server/fastapi/extension.py +121 -0
  26. stemtrace/server/fastapi/router.py +42 -0
  27. stemtrace/server/store.py +260 -0
  28. stemtrace/server/ui/__init__.py +5 -0
  29. stemtrace/server/ui/frontend/biome.json +58 -0
  30. stemtrace/server/ui/frontend/dist/assets/index-CCs7iO1n.css +1 -0
  31. stemtrace/server/ui/frontend/dist/assets/index-Dao0_Q6x.js +14 -0
  32. stemtrace/server/ui/frontend/dist/index.html +17 -0
  33. stemtrace/server/ui/frontend/index.html +16 -0
  34. stemtrace/server/ui/frontend/package-lock.json +4049 -0
  35. stemtrace/server/ui/frontend/package.json +46 -0
  36. stemtrace/server/ui/frontend/playwright.config.ts +40 -0
  37. stemtrace/server/ui/frontend/postcss.config.js +5 -0
  38. stemtrace/server/ui/frontend/src/api/client.ts +173 -0
  39. stemtrace/server/ui/frontend/src/api/queries.ts +151 -0
  40. stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +244 -0
  41. stemtrace/server/ui/frontend/src/components/Filters.tsx +116 -0
  42. stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +453 -0
  43. stemtrace/server/ui/frontend/src/components/TaskList.tsx +172 -0
  44. stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +27 -0
  45. stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +144 -0
  46. stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +224 -0
  47. stemtrace/server/ui/frontend/src/components/Tooltip.tsx +54 -0
  48. stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +143 -0
  49. stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +56 -0
  50. stemtrace/server/ui/frontend/src/index.css +104 -0
  51. stemtrace/server/ui/frontend/src/main.tsx +44 -0
  52. stemtrace/server/ui/frontend/src/routes/__root.tsx +103 -0
  53. stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +51 -0
  54. stemtrace/server/ui/frontend/src/routes/graphs.tsx +186 -0
  55. stemtrace/server/ui/frontend/src/routes/index.tsx +119 -0
  56. stemtrace/server/ui/frontend/src/routes/registry.tsx +211 -0
  57. stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +313 -0
  58. stemtrace/server/ui/frontend/src/utils/format.ts +38 -0
  59. stemtrace/server/ui/frontend/tests/graphs.spec.ts +485 -0
  60. stemtrace/server/ui/frontend/tests/registry.spec.ts +117 -0
  61. stemtrace/server/ui/frontend/tests/task-detail.spec.ts +147 -0
  62. stemtrace/server/ui/frontend/tests/tasks.spec.ts +92 -0
  63. stemtrace/server/ui/frontend/tsconfig.json +32 -0
  64. stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +1 -0
  65. stemtrace/server/ui/frontend/vite.config.ts +30 -0
  66. stemtrace/server/ui/static.py +56 -0
  67. stemtrace/server/websocket.py +99 -0
  68. stemtrace-0.1.0.dist-info/METADATA +428 -0
  69. stemtrace-0.1.0.dist-info/RECORD +72 -0
  70. stemtrace-0.1.0.dist-info/WHEEL +4 -0
  71. stemtrace-0.1.0.dist-info/entry_points.txt +2 -0
  72. stemtrace-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Filter controls for task list with date range picker.
3
+ */
4
+
5
+ import type { DateRange } from 'react-day-picker'
6
+ import { formatDateParam, parseDateParam } from '@/utils/format'
7
+ import { DateRangePicker } from './DateRangePicker'
8
+
9
+ interface FiltersProps {
10
+ filters: {
11
+ state: string | undefined
12
+ name: string
13
+ from_date?: string
14
+ to_date?: string
15
+ }
16
+ onFiltersChange: (filters: {
17
+ state: string | undefined
18
+ name: string
19
+ from_date?: string
20
+ to_date?: string
21
+ }) => void
22
+ }
23
+
24
+ const STATES = [
25
+ { value: undefined, label: 'All' },
26
+ { value: 'PENDING', label: 'Pending' },
27
+ { value: 'STARTED', label: 'Started' },
28
+ { value: 'SUCCESS', label: 'Success' },
29
+ { value: 'FAILURE', label: 'Failure' },
30
+ { value: 'RETRY', label: 'Retry' },
31
+ { value: 'REVOKED', label: 'Revoked' },
32
+ ]
33
+
34
+ export function Filters({ filters, onFiltersChange }: FiltersProps) {
35
+ const dateRange: DateRange | undefined =
36
+ filters.from_date || filters.to_date
37
+ ? {
38
+ from: parseDateParam(filters.from_date),
39
+ to: parseDateParam(filters.to_date),
40
+ }
41
+ : undefined
42
+
43
+ const handleDateChange = (range: DateRange | undefined) => {
44
+ onFiltersChange({
45
+ ...filters,
46
+ from_date: formatDateParam(range?.from),
47
+ to_date: formatDateParam(range?.to),
48
+ })
49
+ }
50
+
51
+ const hasActiveFilters = filters.state || filters.name || filters.from_date || filters.to_date
52
+
53
+ return (
54
+ <div className="flex flex-wrap items-center gap-4 p-4 bg-slate-900/50 rounded-lg border border-slate-800">
55
+ {/* State filter */}
56
+ <div className="flex items-center gap-2">
57
+ <span className="text-sm text-slate-400">State:</span>
58
+ <div className="flex gap-1">
59
+ {STATES.map((state) => (
60
+ <button
61
+ type="button"
62
+ key={state.value ?? 'all'}
63
+ onClick={() => onFiltersChange({ ...filters, state: state.value })}
64
+ className={`px-2 py-1 text-xs rounded transition-colors ${
65
+ filters.state === state.value
66
+ ? 'bg-slate-700 text-slate-100'
67
+ : 'bg-slate-800 text-slate-400 hover:bg-slate-750 hover:text-slate-300'
68
+ }`}
69
+ >
70
+ {state.label}
71
+ </button>
72
+ ))}
73
+ </div>
74
+ </div>
75
+
76
+ {/* Date range picker */}
77
+ <div className="flex items-center gap-2">
78
+ <span className="text-sm text-slate-400">Date:</span>
79
+ <DateRangePicker value={dateRange} onChange={handleDateChange} />
80
+ </div>
81
+
82
+ {/* Name search */}
83
+ <div className="flex items-center gap-2 flex-1 min-w-[200px]">
84
+ <label htmlFor="task-search" className="text-sm text-slate-400">
85
+ Search:
86
+ </label>
87
+ <input
88
+ id="task-search"
89
+ type="text"
90
+ value={filters.name}
91
+ onChange={(e) => onFiltersChange({ ...filters, name: e.target.value })}
92
+ placeholder="Filter by task name..."
93
+ className="flex-1 px-3 py-1.5 text-sm bg-slate-800 border border-slate-700 rounded-lg text-slate-200 placeholder-slate-500 focus:outline-none focus:border-slate-600"
94
+ />
95
+ </div>
96
+
97
+ {/* Clear filters */}
98
+ {hasActiveFilters && (
99
+ <button
100
+ type="button"
101
+ onClick={() =>
102
+ onFiltersChange({
103
+ state: undefined,
104
+ name: '',
105
+ from_date: undefined,
106
+ to_date: undefined,
107
+ })
108
+ }
109
+ className="text-xs text-slate-500 hover:text-slate-300"
110
+ >
111
+ Clear
112
+ </button>
113
+ )}
114
+ </div>
115
+ )
116
+ }
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Task flow graph visualization using React Flow.
3
+ *
4
+ * GROUP and CHORD nodes are rendered as containers with child tasks inside.
5
+ */
6
+
7
+ import {
8
+ Background,
9
+ BackgroundVariant,
10
+ Controls,
11
+ type Edge,
12
+ type Node,
13
+ Position,
14
+ ReactFlow,
15
+ useEdgesState,
16
+ useNodesState,
17
+ } from '@xyflow/react'
18
+ import { useMemo } from 'react'
19
+ import '@xyflow/react/dist/style.css'
20
+ import { Link } from '@tanstack/react-router'
21
+ import type { GraphNode, NodeType } from '@/api/client'
22
+ import { formatDuration, formatTime } from '@/utils/format'
23
+
24
+ interface TaskGraphProps {
25
+ nodes: Record<string, GraphNode>
26
+ rootId: string
27
+ }
28
+
29
+ const stateColors: Record<string, { bg: string; border: string }> = {
30
+ PENDING: { bg: '#374151', border: '#4B5563' },
31
+ RECEIVED: { bg: '#374151', border: '#4B5563' },
32
+ STARTED: { bg: '#1E40AF', border: '#3B82F6' },
33
+ SUCCESS: { bg: '#166534', border: '#22C55E' },
34
+ FAILURE: { bg: '#991B1B', border: '#EF4444' },
35
+ RETRY: { bg: '#92400E', border: '#F59E0B' },
36
+ REVOKED: { bg: '#5B21B6', border: '#8B5CF6' },
37
+ REJECTED: { bg: '#7F1D1D', border: '#DC2626' },
38
+ }
39
+
40
+ const syntheticColors: Record<NodeType, { bg: string; border: string }> = {
41
+ TASK: { bg: '#0F172A', border: '#475569' },
42
+ GROUP: { bg: '#1E1B4B', border: '#6366F1' },
43
+ CHORD: { bg: '#312E81', border: '#818CF8' },
44
+ }
45
+
46
+ const TASK_NODE_MIN_WIDTH = 160
47
+ const TASK_NODE_HEIGHT = 100
48
+ const CONTAINER_PADDING = 16
49
+ const CONTAINER_HEADER = 40
50
+ const CHILD_SPACING = 12
51
+ const LEVEL_SPACING = 320
52
+
53
+ function buildGraphElements(
54
+ nodes: Record<string, GraphNode>,
55
+ rootId: string,
56
+ ): { nodes: Node[]; edges: Edge[] } {
57
+ const flowNodes: Node[] = []
58
+ const flowEdges: Edge[] = []
59
+ const visited = new Set<string>()
60
+ const levels: Map<string, number> = new Map()
61
+ const positions: Map<number, number> = new Map()
62
+
63
+ // Track which nodes are inside containers (GROUP/CHORD header tasks)
64
+ // For CHORD, callbacks are NOT inside - they connect via edge
65
+ const containerChildren = new Set<string>()
66
+ const chordCallbacks = new Set<string>()
67
+
68
+ for (const node of Object.values(nodes)) {
69
+ const nodeType = node.node_type ?? 'TASK'
70
+ if (nodeType === 'GROUP') {
71
+ for (const childId of node.children) {
72
+ containerChildren.add(childId)
73
+ }
74
+ } else if (nodeType === 'CHORD') {
75
+ // For CHORD, header tasks go inside, callback stays outside
76
+ // The CHORD node's chord_id field contains the callback task_id
77
+ for (const childId of node.children) {
78
+ if (childId === node.chord_id) {
79
+ // This is the callback - render outside with edge
80
+ chordCallbacks.add(childId)
81
+ } else {
82
+ // Header task - render inside container
83
+ containerChildren.add(childId)
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ // BFS to assign levels (skip container children - they're positioned inside)
90
+ const queue: Array<{ id: string; level: number }> = [{ id: rootId, level: 0 }]
91
+
92
+ while (queue.length > 0) {
93
+ const { id, level } = queue.shift()!
94
+ if (visited.has(id)) continue
95
+ visited.add(id)
96
+
97
+ const node = nodes[id]
98
+ if (!node) continue
99
+
100
+ levels.set(id, level)
101
+
102
+ const nodeType = node.node_type ?? 'TASK'
103
+ const isContainer = nodeType === 'GROUP' || nodeType === 'CHORD'
104
+
105
+ for (const childId of node.children) {
106
+ if (!visited.has(childId)) {
107
+ // Container children don't advance the level (they're inside)
108
+ if (isContainer && containerChildren.has(childId)) {
109
+ // Skip - will be positioned inside container
110
+ } else {
111
+ queue.push({ id: childId, level: level + 1 })
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ // Create flow nodes
118
+ visited.clear()
119
+ const queue2: string[] = [rootId]
120
+
121
+ while (queue2.length > 0) {
122
+ const id = queue2.shift()!
123
+ if (visited.has(id)) continue
124
+ visited.add(id)
125
+
126
+ const node = nodes[id]
127
+ if (!node) continue
128
+
129
+ // Skip if this node is inside a container (handled separately)
130
+ if (containerChildren.has(id)) continue
131
+
132
+ const level = levels.get(id) ?? 0
133
+ const nodeType = node.node_type ?? 'TASK'
134
+ const isContainer = nodeType === 'GROUP' || nodeType === 'CHORD'
135
+ const stateColor = stateColors[node.state] ?? stateColors.PENDING
136
+ const syntheticColor = syntheticColors[nodeType]
137
+
138
+ if (isContainer) {
139
+ // For CHORD, only header tasks go inside (not callbacks)
140
+ const headerChildren = node.children.filter((cid) => !chordCallbacks.has(cid))
141
+ const childCount = headerChildren.length
142
+
143
+ // Vertical layout: children stacked in a column
144
+ const containerWidth = TASK_NODE_MIN_WIDTH + CONTAINER_PADDING * 2
145
+ const containerHeight =
146
+ CONTAINER_HEADER +
147
+ CONTAINER_PADDING * 2 +
148
+ Math.max(1, childCount) * TASK_NODE_HEIGHT +
149
+ Math.max(0, childCount - 1) * CHILD_SPACING
150
+
151
+ const yPos = positions.get(level) ?? 0
152
+ positions.set(level, yPos + containerHeight + 40)
153
+
154
+ // Container node (parent) - use 'default' type to enable edge handles
155
+ // React Flow's 'group' type doesn't render connection handles
156
+ flowNodes.push({
157
+ id,
158
+ type: 'default',
159
+ position: { x: level * LEVEL_SPACING, y: yPos },
160
+ data: { label: '' },
161
+ style: {
162
+ width: containerWidth,
163
+ height: containerHeight,
164
+ backgroundColor: syntheticColor.bg,
165
+ border: `2px dashed ${syntheticColor.border}`,
166
+ borderRadius: '16px',
167
+ padding: 0,
168
+ },
169
+ sourcePosition: Position.Right,
170
+ targetPosition: Position.Left,
171
+ className: 'container-node',
172
+ })
173
+
174
+ // Header label inside container (no handles/connectors)
175
+ flowNodes.push({
176
+ id: `${id}-header`,
177
+ type: 'default',
178
+ position: { x: CONTAINER_PADDING, y: 8 },
179
+ parentId: id,
180
+ extent: 'parent',
181
+ draggable: false,
182
+ selectable: false,
183
+ connectable: false,
184
+ data: {
185
+ label: (
186
+ <div className="flex items-center gap-3">
187
+ <span className="text-xs font-mono text-indigo-300 uppercase">{nodeType}</span>
188
+ <span
189
+ className="text-xs px-1.5 py-0.5 rounded"
190
+ style={{ backgroundColor: stateColor.bg, color: stateColor.border }}
191
+ >
192
+ {node.state}
193
+ </span>
194
+ </div>
195
+ ),
196
+ },
197
+ className: 'header-label-node',
198
+ style: {
199
+ background: 'transparent',
200
+ border: 'none',
201
+ padding: 0,
202
+ width: 'auto',
203
+ pointerEvents: 'none' as const,
204
+ },
205
+ })
206
+
207
+ // Header task nodes inside container (vertical column layout)
208
+ headerChildren.forEach((childId, idx) => {
209
+ const childNode = nodes[childId]
210
+ if (!childNode) return
211
+
212
+ const childStateColor = stateColors[childNode.state] ?? stateColors.PENDING
213
+ const duration = formatDuration(childNode.duration_ms)
214
+ const startTime = formatTime(childNode.first_seen)
215
+
216
+ flowNodes.push({
217
+ id: childId,
218
+ position: {
219
+ x: CONTAINER_PADDING,
220
+ y: CONTAINER_HEADER + CONTAINER_PADDING + idx * (TASK_NODE_HEIGHT + CHILD_SPACING),
221
+ },
222
+ parentId: id,
223
+ extent: 'parent',
224
+ data: {
225
+ label: (
226
+ <Link
227
+ to="/tasks/$taskId"
228
+ params={{ taskId: childId }}
229
+ className="block p-2 text-left h-full"
230
+ >
231
+ <div className="text-xs text-slate-400 font-mono mb-1">{childId.slice(0, 8)}</div>
232
+ <div className="text-sm text-slate-100 font-medium">
233
+ {childNode.name.split('.').pop()}
234
+ </div>
235
+ {(startTime || duration) && (
236
+ <div className="text-xs text-slate-500 mt-1">
237
+ {startTime && <span>{startTime}</span>}
238
+ {startTime && duration && <span className="mx-1">·</span>}
239
+ {duration && <span>{duration}</span>}
240
+ </div>
241
+ )}
242
+ <div
243
+ className="text-xs mt-1 px-1.5 py-0.5 rounded inline-block"
244
+ style={{ backgroundColor: childStateColor.bg, color: childStateColor.border }}
245
+ >
246
+ {childNode.state}
247
+ </div>
248
+ </Link>
249
+ ),
250
+ },
251
+ style: {
252
+ minWidth: TASK_NODE_MIN_WIDTH,
253
+ width: 'fit-content',
254
+ height: TASK_NODE_HEIGHT,
255
+ backgroundColor: '#0F172A',
256
+ border: `2px solid ${childStateColor.border}`,
257
+ borderRadius: '8px',
258
+ padding: 0,
259
+ },
260
+ sourcePosition: Position.Right,
261
+ targetPosition: Position.Left,
262
+ className: 'container-child',
263
+ })
264
+
265
+ visited.add(childId)
266
+ })
267
+
268
+ // Create callback nodes to the RIGHT of the container (for CHORD)
269
+ if (nodeType === 'CHORD') {
270
+ for (const childId of node.children) {
271
+ if (chordCallbacks.has(childId)) {
272
+ const callbackNode = nodes[childId]
273
+ if (!callbackNode) continue
274
+
275
+ const callbackStateColor = stateColors[callbackNode.state] ?? stateColors.PENDING
276
+ const callbackDuration = formatDuration(callbackNode.duration_ms)
277
+ const callbackStartTime = formatTime(callbackNode.first_seen)
278
+
279
+ // Position callback to the RIGHT of container, vertically centered
280
+ const callbackX = level * LEVEL_SPACING + containerWidth + 60
281
+ const callbackY = yPos + (containerHeight - TASK_NODE_HEIGHT) / 2
282
+
283
+ flowNodes.push({
284
+ id: childId,
285
+ position: { x: callbackX, y: callbackY },
286
+ data: {
287
+ label: (
288
+ <Link
289
+ to="/tasks/$taskId"
290
+ params={{ taskId: childId }}
291
+ className="block p-2 text-left h-full"
292
+ >
293
+ <div className="text-xs text-slate-400 font-mono mb-1">
294
+ {childId.slice(0, 8)}
295
+ </div>
296
+ <div className="text-sm text-slate-100 font-medium">
297
+ {callbackNode.name.split('.').pop()}
298
+ </div>
299
+ {(callbackStartTime || callbackDuration) && (
300
+ <div className="text-xs text-slate-500 mt-1">
301
+ {callbackStartTime && <span>{callbackStartTime}</span>}
302
+ {callbackStartTime && callbackDuration && <span className="mx-1">·</span>}
303
+ {callbackDuration && <span>{callbackDuration}</span>}
304
+ </div>
305
+ )}
306
+ <div
307
+ className="text-xs mt-1 px-1.5 py-0.5 rounded inline-block"
308
+ style={{
309
+ backgroundColor: callbackStateColor.bg,
310
+ color: callbackStateColor.border,
311
+ }}
312
+ >
313
+ {callbackNode.state}
314
+ </div>
315
+ </Link>
316
+ ),
317
+ },
318
+ style: {
319
+ minWidth: TASK_NODE_MIN_WIDTH,
320
+ width: 'fit-content',
321
+ height: TASK_NODE_HEIGHT,
322
+ backgroundColor: '#0F172A',
323
+ border: `2px solid ${callbackStateColor.border}`,
324
+ borderRadius: '8px',
325
+ padding: 0,
326
+ },
327
+ sourcePosition: Position.Right,
328
+ targetPosition: Position.Left,
329
+ })
330
+
331
+ // Edges from ALL header tasks to callback (chord aggregation)
332
+ // Each header task's completion contributes to triggering the callback
333
+ for (const headerId of headerChildren) {
334
+ flowEdges.push({
335
+ id: `${id}-callback-${headerId}-${childId}`,
336
+ source: headerId,
337
+ target: childId,
338
+ animated: callbackNode.state === 'STARTED',
339
+ style: { stroke: '#475569' },
340
+ })
341
+ }
342
+
343
+ visited.add(childId)
344
+ }
345
+ }
346
+ }
347
+ } else {
348
+ // Regular task node
349
+ const yPos = positions.get(level) ?? 0
350
+ positions.set(level, yPos + 120)
351
+
352
+ const duration = formatDuration(node.duration_ms)
353
+ const startTime = formatTime(node.first_seen)
354
+
355
+ flowNodes.push({
356
+ id,
357
+ position: { x: level * LEVEL_SPACING, y: yPos },
358
+ data: {
359
+ label: (
360
+ <Link to="/tasks/$taskId" params={{ taskId: id }} className="block p-3 text-left">
361
+ <div className="text-xs text-slate-400 font-mono mb-1">{id.slice(0, 8)}</div>
362
+ <div className="text-sm text-slate-100 font-medium whitespace-nowrap">
363
+ {node.name.split('.').pop()}
364
+ </div>
365
+ {(startTime || duration) && (
366
+ <div className="text-xs text-slate-500 mt-1">
367
+ {startTime && <span>{startTime}</span>}
368
+ {startTime && duration && <span className="mx-1">·</span>}
369
+ {duration && <span>{duration}</span>}
370
+ </div>
371
+ )}
372
+ <div
373
+ className="text-xs mt-2 px-1.5 py-0.5 rounded inline-block"
374
+ style={{ backgroundColor: stateColor.bg, color: stateColor.border }}
375
+ >
376
+ {node.state}
377
+ </div>
378
+ </Link>
379
+ ),
380
+ },
381
+ style: {
382
+ padding: 0,
383
+ borderRadius: '8px',
384
+ border: `2px solid ${stateColor.border}`,
385
+ backgroundColor: '#0F172A',
386
+ minWidth: TASK_NODE_MIN_WIDTH,
387
+ width: 'fit-content',
388
+ },
389
+ sourcePosition: Position.Right,
390
+ targetPosition: Position.Left,
391
+ })
392
+
393
+ // Edges to children (not inside a container)
394
+ for (const childId of node.children) {
395
+ // Skip edges to nodes that are inside containers
396
+ if (containerChildren.has(childId)) continue
397
+
398
+ const childNode = nodes[childId]
399
+ flowEdges.push({
400
+ id: `${id}-${childId}`,
401
+ source: id,
402
+ target: childId,
403
+ animated: childNode?.state === 'STARTED',
404
+ style: { stroke: '#475569' },
405
+ })
406
+ }
407
+ }
408
+
409
+ // Queue non-container children
410
+ for (const childId of node.children) {
411
+ if (!visited.has(childId) && !containerChildren.has(childId)) {
412
+ queue2.push(childId)
413
+ }
414
+ }
415
+ }
416
+
417
+ return { nodes: flowNodes, edges: flowEdges }
418
+ }
419
+
420
+ export function TaskGraph({ nodes: graphNodes, rootId }: TaskGraphProps) {
421
+ const { nodes: initialNodes, edges: initialEdges } = useMemo(
422
+ () => buildGraphElements(graphNodes, rootId),
423
+ [graphNodes, rootId],
424
+ )
425
+
426
+ const [nodes, , onNodesChange] = useNodesState(initialNodes)
427
+ const [edges, , onEdgesChange] = useEdgesState(initialEdges)
428
+
429
+ if (Object.keys(graphNodes).length === 0) {
430
+ return (
431
+ <div className="flex items-center justify-center h-full text-slate-400">
432
+ No nodes in graph
433
+ </div>
434
+ )
435
+ }
436
+
437
+ return (
438
+ <ReactFlow
439
+ nodes={nodes}
440
+ edges={edges}
441
+ onNodesChange={onNodesChange}
442
+ onEdgesChange={onEdgesChange}
443
+ fitView
444
+ fitViewOptions={{ padding: 0.2 }}
445
+ minZoom={0.1}
446
+ maxZoom={2}
447
+ proOptions={{ hideAttribution: true }}
448
+ >
449
+ <Controls className="bg-slate-800 border-slate-700" />
450
+ <Background variant={BackgroundVariant.Dots} gap={16} size={1} color="#1E293B" />
451
+ </ReactFlow>
452
+ )
453
+ }