Skip to content

Commit 9f360d5

Browse files
cristipufuclaude
andcommitted
fix: use UTC datetimes and reset graph state between chat turns
Backend: replace all naive datetime.now() with datetime.now(timezone.utc) and datetime.fromtimestamp(..., tz=timezone.utc) so serialized ISO strings include +00:00 suffix. Frontend toLocaleTimeString() handles UTC→local conversion automatically. Frontend: reset stateEvents and activeNodes when __start__ fires at the beginning of each chat turn, so the graph highlights start fresh instead of retaining completed status from prior turns. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 695d147 commit 9f360d5

18 files changed

Lines changed: 197 additions & 111 deletions

File tree

src/uipath/dev/infrastructure/logging_handlers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import os
77
import re
88
import threading
9-
from datetime import datetime
9+
from datetime import datetime, timezone
1010
from typing import Callable, Pattern
1111

1212
from uipath.runtime.logging import UiPathRuntimeExecutionLogHandler
@@ -35,7 +35,7 @@ def emit(self, record: logging.LogRecord):
3535
run_id=self.run_id,
3636
level=record.levelname,
3737
message=self.format(record),
38-
timestamp=datetime.fromtimestamp(record.created),
38+
timestamp=datetime.fromtimestamp(record.created, tz=timezone.utc),
3939
)
4040
self.callback(log_data)
4141
except Exception:

src/uipath/dev/infrastructure/tracing_exporter.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Custom OpenTelemetry trace exporter for CLI UI integration."""
22

33
import logging
4-
from datetime import datetime
4+
from datetime import datetime, timezone
55
from typing import Callable, Sequence
66

77
from opentelemetry import trace
@@ -82,7 +82,7 @@ def _export_span(self, span: ReadableSpan):
8282
trace_id=trace_id,
8383
status=status,
8484
duration_ms=duration_ms,
85-
timestamp=datetime.fromtimestamp(start_time),
85+
timestamp=datetime.fromtimestamp(start_time, tz=timezone.utc),
8686
attributes=dict(span.attributes) if span.attributes else {},
8787
)
8888

@@ -97,7 +97,9 @@ def _export_span(self, span: ReadableSpan):
9797
run_id=run_id_val,
9898
level=log_level,
9999
message=event.name,
100-
timestamp=datetime.fromtimestamp(event.timestamp / 1_000_000_000),
100+
timestamp=datetime.fromtimestamp(
101+
event.timestamp / 1_000_000_000, tz=timezone.utc
102+
),
101103
)
102104
self.on_log(log_data)
103105

src/uipath/dev/models/chat.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Aggregates conversation messages from conversation events."""
22

3-
from datetime import datetime
3+
from datetime import datetime, timezone
44
from uuid import uuid4
55

66
from uipath.core.chat import (
@@ -178,7 +178,7 @@ def get_timestamp(self, ev: UiPathConversationMessageEvent) -> str:
178178
"""Choose timestamp from event if available, else fallback."""
179179
if ev.start and ev.start.timestamp:
180180
return ev.start.timestamp
181-
return datetime.now().isoformat()
181+
return datetime.now(timezone.utc).isoformat()
182182

183183
def get_role(self, ev: UiPathConversationMessageEvent) -> str:
184184
"""Infer the role of the message from the event."""
@@ -189,7 +189,7 @@ def get_role(self, ev: UiPathConversationMessageEvent) -> str:
189189

190190
def get_user_message(user_text: str) -> UiPathConversationMessage:
191191
"""Build a user message from text input."""
192-
timestamp = datetime.now().isoformat()
192+
timestamp = datetime.now(timezone.utc).isoformat()
193193
return UiPathConversationMessage(
194194
message_id=str(uuid4()),
195195
created_at=timestamp,
@@ -216,7 +216,7 @@ def get_user_message_event(
216216
"""Build a conversation event representing a user message from text input."""
217217
message_id = str(uuid4())
218218
content_part_id = str(uuid4())
219-
timestamp = datetime.now().isoformat()
219+
timestamp = datetime.now(timezone.utc).isoformat()
220220

221221
msg_start = UiPathConversationMessageStartEvent(
222222
role=role,

src/uipath/dev/models/data.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Plain data classes for inter-component communication (no Textual dependency)."""
22

33
from dataclasses import dataclass, field
4-
from datetime import datetime
4+
from datetime import datetime, timezone
55
from typing import Any
66

77
from uipath.core.chat import UiPathConversationMessage, UiPathConversationMessageEvent
@@ -28,7 +28,7 @@ class TraceData:
2828
trace_id: str | None = None
2929
status: str = "running"
3030
duration_ms: float | None = None
31-
timestamp: datetime = field(default_factory=datetime.now)
31+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
3232
attributes: dict[str, Any] = field(default_factory=dict)
3333

3434

@@ -41,7 +41,7 @@ class StateData:
4141
qualified_node_name: str | None = None
4242
phase: str | None = None
4343
payload: dict[str, Any] | None = None
44-
timestamp: datetime = field(default_factory=datetime.now)
44+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
4545

4646

4747
@dataclass

src/uipath/dev/models/execution.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Models for representing execution runs and their data."""
22

33
import os
4-
from datetime import datetime
4+
from datetime import datetime, timezone
55
from enum import Enum
66
from typing import Any, cast
77
from uuid import uuid4
@@ -38,7 +38,7 @@ def __init__(
3838
self.mode = mode
3939
self.resume_data: Any | None = None
4040
self.output_data: dict[str, Any] | str | None = None
41-
self.start_time = datetime.now()
41+
self.start_time = datetime.now(timezone.utc)
4242
self.end_time: datetime | None = None
4343
self.status = "pending" # pending, running, completed, failed, suspended
4444
self.traces: list[TraceData] = []
@@ -58,7 +58,7 @@ def duration(self) -> str:
5858
delta = self.end_time - self.start_time
5959
return f"{delta.total_seconds():.1f}s"
6060
elif self.start_time:
61-
delta = datetime.now() - self.start_time
61+
delta = datetime.now(timezone.utc) - self.start_time
6262
return f"{delta.total_seconds():.1f}s"
6363
return "0.0s"
6464

src/uipath/dev/models/messages.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Messages used for inter-component communication in the UiPath Developer Console."""
22

3-
from datetime import datetime
3+
from datetime import datetime, timezone
44
from typing import Any
55

66
from rich.console import RenderableType
@@ -24,7 +24,7 @@ def __init__(
2424
self.run_id = run_id
2525
self.level = level
2626
self.message = message
27-
self.timestamp = timestamp or datetime.now()
27+
self.timestamp = timestamp or datetime.now(timezone.utc)
2828
super().__init__()
2929

3030
@classmethod
@@ -61,7 +61,7 @@ def __init__(
6161
self.trace_id = trace_id
6262
self.status = status
6363
self.duration_ms = duration_ms
64-
self.timestamp = timestamp or datetime.now()
64+
self.timestamp = timestamp or datetime.now(timezone.utc)
6565
self.attributes = attributes or {}
6666
super().__init__()
6767

src/uipath/dev/server/frontend/src/App.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export default function App() {
2828
setStateEvents,
2929
setGraphCache,
3030
setActiveNode,
31+
removeActiveNode,
3132
} = useRunStore();
3233
const { view, runId: routeRunId, setupEntrypoint, setupMode, navigate } = useHashRoute();
3334

@@ -97,15 +98,18 @@ export default function App() {
9798
payload: s.payload,
9899
})),
99100
);
100-
// Seed activeNodes from historical events so the next WS event has proper prev context
101+
// Seed activeNodes from historical events (replay all to get correct prev + executing)
101102
if (detail.status !== "completed" && detail.status !== "failed") {
102-
const lastStarted = [...detail.states].reverse().find((s) => s.phase === "started");
103-
if (lastStarted) {
104-
setActiveNode(runId, lastStarted.node_name, lastStarted.qualified_node_name);
103+
for (const s of detail.states) {
104+
if (s.phase === "started") {
105+
setActiveNode(runId, s.node_name, s.qualified_node_name);
106+
} else if (s.phase === "completed") {
107+
removeActiveNode(runId, s.node_name);
108+
}
105109
}
106110
}
107111
}
108-
}, [upsertRun, setTraces, setLogs, setChatMessages, setStateEvents, setGraphCache, setActiveNode]);
112+
}, [upsertRun, setTraces, setLogs, setChatMessages, setStateEvents, setGraphCache, setActiveNode, removeActiveNode]);
109113

110114
// Subscribe to selected run
111115
useEffect(() => {

src/uipath/dev/server/frontend/src/components/graph/GraphPanel.tsx

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -448,18 +448,32 @@ export default function GraphPanel({ entrypoint, runId, breakpointNode, breakpoi
448448
);
449449
}, [breakpointNode, layoutSeq, setNodes]);
450450

451+
const stateEvents = useRunStore((s) => s.stateEvents[runId]);
452+
451453
// Highlight edges + nodes during execution
452454
// - Paused at breakpoint: edges INTO breakpoint node + edges to next_nodes
453-
// - Running: edges OUT of completed node, target nodes of those edges
455+
// - Running: edges OUT of executing nodes, target nodes of those edges
454456
// - __start__: highlighted on first state event; __end__: highlighted when run completes
455457
useEffect(() => {
456458
const isPaused = !!breakpointNode;
457-
let matchIds = new Set<string>(); // Full React Flow node IDs of the "current" node
459+
let matchIds = new Set<string>(); // Full React Flow node IDs of the "current" node(s)
458460
const prevNodeIds = new Set<string>(); // Full RF IDs of the previous node (for edge filtering when paused)
459461
const nextNodeIds = new Set<string>(); // Full RF IDs of breakpoint next_nodes
460462
const activeTargetIds = new Set<string>(); // Full RF IDs for isActiveNode
461463
const nodeTypeById = new Map<string, string>();
462464

465+
// Derive currently-executing nodes from the full event log (always consistent)
466+
const executingNodes = new Map<string, string | null>(); // nodeName → qualifiedNodeName
467+
if (stateEvents) {
468+
for (const evt of stateEvents) {
469+
if (evt.phase === "started") {
470+
executingNodes.set(evt.node_name, evt.qualified_node_name ?? null);
471+
} else if (evt.phase === "completed") {
472+
executingNodes.delete(evt.node_name);
473+
}
474+
}
475+
}
476+
463477
// 1) Build matchIds, nextNodeIds, node type map
464478
setNodes((nds) => {
465479
for (const n of nds) {
@@ -494,33 +508,38 @@ export default function GraphPanel({ entrypoint, runId, breakpointNode, breakpoi
494508
if (activeNode?.prev) {
495509
findNodeIds(activeNode.prev).forEach((id) => prevNodeIds.add(id));
496510
}
497-
} else if (activeNode) {
498-
// Try qualified name first (exact match via "subgraph:node" → "subgraph/node")
499-
const qualifiedName = activeNode.qualifiedNodeName;
500-
if (qualifiedName) {
501-
const qualifiedId = qualifiedName.replace(/:/g, "/");
502-
for (const n of nds) {
503-
if (n.id === qualifiedId) {
504-
matchIds.add(n.id);
505-
}
511+
} else if (executingNodes.size > 0) {
512+
// Build label → RF ID lookup once
513+
const labelToIds = new Map<string, Set<string>>();
514+
for (const n of nds) {
515+
const label = n.data?.label as string | undefined;
516+
if (!label) continue;
517+
const plainId = n.id.includes("/") ? n.id.split("/").pop()! : n.id;
518+
for (const key of [plainId, label]) {
519+
let s = labelToIds.get(key);
520+
if (!s) { s = new Set(); labelToIds.set(key, s); }
521+
s.add(n.id);
506522
}
507523
}
508-
// Fallback: label/plainId matching
509-
if (matchIds.size === 0) {
510-
const labelToIds = new Map<string, Set<string>>();
511-
for (const n of nds) {
512-
const label = n.data?.label as string | undefined;
513-
if (!label) continue;
514-
const plainId = n.id.includes("/") ? n.id.split("/").pop()! : n.id;
515-
for (const key of [plainId, label]) {
516-
let s = labelToIds.get(key);
517-
if (!s) { s = new Set(); labelToIds.set(key, s); }
518-
s.add(n.id);
524+
525+
for (const [nodeName, qualifiedNodeName] of executingNodes) {
526+
let found = false;
527+
// Try qualified name first (exact match via "subgraph:node" → "subgraph/node")
528+
if (qualifiedNodeName) {
529+
const qualifiedId = qualifiedNodeName.replace(/:/g, "/");
530+
for (const n of nds) {
531+
if (n.id === qualifiedId) {
532+
matchIds.add(n.id);
533+
found = true;
534+
}
519535
}
520536
}
521-
matchIds = labelToIds.get(activeNode.current) ?? new Set<string>();
537+
// Fallback: label/plainId matching
538+
if (!found) {
539+
const ids = labelToIds.get(nodeName);
540+
if (ids) ids.forEach((id) => matchIds.add(id));
541+
}
522542
}
523-
524543
}
525544

526545
return nds;
@@ -542,7 +561,7 @@ export default function GraphPanel({ entrypoint, runId, breakpointNode, breakpoi
542561
isActive = intoBreakpoint
543562
|| (matchIds.has(e.source) && nextNodeIds.has(e.target));
544563
} else {
545-
// Running: edges OUT of completed node
564+
// Running: edges OUT of executing nodes
546565
isActive = matchIds.has(e.source);
547566
// For __end__: also highlight edges INTO it
548567
if (!isActive && nodeTypeById.get(e.target) === "endNode" && matchIds.has(e.target)) {
@@ -596,9 +615,7 @@ export default function GraphPanel({ entrypoint, runId, breakpointNode, breakpoi
596615
: n;
597616
}),
598617
);
599-
}, [activeNode, breakpointNode, breakpointNextNodes, runStatus, layoutSeq, setNodes, setEdges]);
600-
601-
const stateEvents = useRunStore((s) => s.stateEvents[runId]);
618+
}, [stateEvents, activeNode, breakpointNode, breakpointNextNodes, runStatus, layoutSeq, setNodes, setEdges]);
602619

603620
// Subscribe to cached graph reactively (populated async from run detail)
604621
const cachedGraph = useRunStore((s) => s.graphCache[runId]);

src/uipath/dev/server/frontend/src/components/traces/TraceTree.tsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useCallback, useRef } from "react";
1+
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
22
import type { TraceSpan } from "../../types/run";
33
import { useRunStore } from "../../store/useRunStore";
44
import SpanDetails from "./SpanDetails";
@@ -129,6 +129,16 @@ function formatDuration(ms: number | null | undefined): string {
129129
return `${(ms / 1000).toFixed(2)}s`;
130130
}
131131

132+
function treeToJson(nodes: TreeNode[]): unknown[] {
133+
return nodes.map((node) => {
134+
const { span } = node;
135+
if (node.children.length > 0) {
136+
return { name: span.span_name, children: treeToJson(node.children) };
137+
}
138+
return { name: span.span_name };
139+
});
140+
}
141+
132142
export default function TraceTree({ traces }: Props) {
133143
const [selectedSpan, setSelectedSpan] = useState<TraceSpan | null>(null);
134144
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
@@ -137,8 +147,18 @@ export default function TraceTree({ traces }: Props) {
137147
return saved ? parseFloat(saved) : 50;
138148
});
139149
const [isDragging, setIsDragging] = useState(false);
150+
const [copied, setCopied] = useState(false);
140151
const tree = buildTree(traces);
141152

153+
const treeJson = useMemo(() => JSON.stringify(treeToJson(tree), null, 2), [traces]);
154+
155+
const copyTree = useCallback(() => {
156+
navigator.clipboard.writeText(treeJson).then(() => {
157+
setCopied(true);
158+
setTimeout(() => setCopied(false), 1500);
159+
});
160+
}, [treeJson]);
161+
142162
const focusedSpan = useRunStore((s) => s.focusedSpan);
143163
const setFocusedSpan = useRunStore((s) => s.setFocusedSpan);
144164
const [scrollToSpanId, setScrollToSpanId] = useState<string | null>(null);
@@ -244,7 +264,23 @@ export default function TraceTree({ traces }: Props) {
244264
return (
245265
<div className="flex h-full trace-tree-container" style={{ cursor: isDragging ? "col-resize" : undefined }}>
246266
{/* Left: tree view */}
247-
<div className="pr-0.5 pt-0.5" style={{ width: `${leftWidth}%` }}>
267+
<div className="pr-0.5 pt-0.5 relative" style={{ width: `${leftWidth}%` }}>
268+
{traces.length > 0 && (
269+
<button
270+
onClick={copyTree}
271+
className="absolute top-2 left-2 z-20 text-[10px] cursor-pointer px-1.5 py-0.5 rounded transition-opacity"
272+
style={{
273+
opacity: copied ? 1 : 0.3,
274+
color: copied ? "var(--success)" : "var(--text-muted)",
275+
background: "var(--bg-secondary)",
276+
border: "1px solid var(--border)",
277+
}}
278+
onMouseEnter={(e) => { e.currentTarget.style.opacity = "1"; }}
279+
onMouseLeave={(e) => { if (!copied) e.currentTarget.style.opacity = "0.3"; }}
280+
>
281+
{copied ? "Copied!" : "Copy JSON"}
282+
</button>
283+
)}
248284
<div ref={treeScrollRef} className="overflow-y-auto h-full p-0.5">
249285
{tree.length === 0 ? (
250286
<div className="flex items-center justify-center h-full">

0 commit comments

Comments
 (0)