Skip to content

Commit 0df6662

Browse files
authored
Merge pull request #80 from UiPath/feat/run-history-eviction
feat: evict old terminal runs to cap history at 50
2 parents 695d147 + 9e0ce67 commit 0df6662

23 files changed

Lines changed: 270 additions & 113 deletions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-dev"
3-
version = "0.0.54"
3+
version = "0.0.55"
44
description = "UiPath Developer Console"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

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/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def __init__(
8383
on_state=self._on_state,
8484
on_interrupt=self._on_interrupt,
8585
debug_bridge_factory=lambda mode: WebDebugBridge(mode=mode),
86+
on_run_removed=self.connection_manager.remove_run_subscriptions,
8687
)
8788

8889
def create_app(self) -> Any:

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]);

0 commit comments

Comments
 (0)