dlab-cli 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.
- dlab/__init__.py +6 -0
- dlab/cli.py +1075 -0
- dlab/config.py +190 -0
- dlab/create_dpack.py +1096 -0
- dlab/create_dpack_wizard.py +1471 -0
- dlab/create_parallel_agent_wizard.py +582 -0
- dlab/data/__init__.py +0 -0
- dlab/data/models.json +1793 -0
- dlab/docker.py +591 -0
- dlab/local.py +269 -0
- dlab/model_fallback.py +360 -0
- dlab/parallel_tool.py +18 -0
- dlab/session.py +389 -0
- dlab/timeline.py +684 -0
- dlab/tui/__init__.py +9 -0
- dlab/tui/app.py +664 -0
- dlab/tui/log_watcher.py +208 -0
- dlab/tui/models.py +438 -0
- dlab/tui/widgets/__init__.py +18 -0
- dlab/tui/widgets/agent_list.py +170 -0
- dlab/tui/widgets/artifacts_pane.py +618 -0
- dlab/tui/widgets/log_view.py +505 -0
- dlab/tui/widgets/search_popup.py +151 -0
- dlab/tui/widgets/status_bar.py +106 -0
- dlab_cli-0.1.0.dist-info/METADATA +237 -0
- dlab_cli-0.1.0.dist-info/RECORD +30 -0
- dlab_cli-0.1.0.dist-info/WHEEL +5 -0
- dlab_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dlab_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- dlab_cli-0.1.0.dist-info/top_level.txt +1 -0
dlab/tui/log_watcher.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Log file watcher using simple polling.
|
|
3
|
+
|
|
4
|
+
Tracks file positions and streams new log events as they are appended.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from queue import Queue
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LogWatcher:
|
|
15
|
+
"""
|
|
16
|
+
Watches log directory for changes via periodic polling.
|
|
17
|
+
|
|
18
|
+
Simple and reliable approach - polls all log files for new content.
|
|
19
|
+
Called periodically from the UI update timer.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, logs_dir: Path) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Initialize watcher.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
logs_dir : Path
|
|
29
|
+
Directory containing log files.
|
|
30
|
+
"""
|
|
31
|
+
self._logs_dir = logs_dir
|
|
32
|
+
self._event_queue: Queue[tuple[str, dict[str, Any]]] = Queue()
|
|
33
|
+
self._file_positions: dict[Path, int] = {}
|
|
34
|
+
self._file_inodes: dict[Path, int] = {} # Track inode for replacement detection
|
|
35
|
+
self._lock = threading.Lock()
|
|
36
|
+
self._running = False
|
|
37
|
+
|
|
38
|
+
def _get_source_name(self, log_path: Path) -> str:
|
|
39
|
+
"""
|
|
40
|
+
Convert log path to source name.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
log_path : Path
|
|
45
|
+
Path to log file.
|
|
46
|
+
|
|
47
|
+
Returns
|
|
48
|
+
-------
|
|
49
|
+
str
|
|
50
|
+
Source name (e.g., "main", "instance-1").
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
rel_path = log_path.relative_to(self._logs_dir)
|
|
54
|
+
if len(rel_path.parts) > 1:
|
|
55
|
+
return f"{rel_path.parent.name}/{rel_path.stem}"
|
|
56
|
+
return rel_path.stem
|
|
57
|
+
except ValueError:
|
|
58
|
+
return log_path.stem
|
|
59
|
+
|
|
60
|
+
def _read_new_lines(self, log_path: Path) -> list[tuple[str, dict[str, Any]]]:
|
|
61
|
+
"""
|
|
62
|
+
Read new lines from a log file.
|
|
63
|
+
|
|
64
|
+
Handles atomic file replacement (common in Docker) by detecting:
|
|
65
|
+
- File inode changed (new file with same name)
|
|
66
|
+
- File size smaller than stored position (truncation)
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
log_path : Path
|
|
71
|
+
Path to log file.
|
|
72
|
+
|
|
73
|
+
Returns
|
|
74
|
+
-------
|
|
75
|
+
list[tuple[str, dict[str, Any]]]
|
|
76
|
+
List of (source, event) tuples.
|
|
77
|
+
"""
|
|
78
|
+
if not log_path.exists() or log_path.suffix != ".log":
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
source = self._get_source_name(log_path)
|
|
82
|
+
events: list[tuple[str, dict[str, Any]]] = []
|
|
83
|
+
|
|
84
|
+
with self._lock:
|
|
85
|
+
position = self._file_positions.get(log_path, 0)
|
|
86
|
+
old_inode = self._file_inodes.get(log_path, 0)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
stat = log_path.stat()
|
|
90
|
+
current_size = stat.st_size
|
|
91
|
+
current_inode = stat.st_ino
|
|
92
|
+
|
|
93
|
+
# Detect file replacement or truncation:
|
|
94
|
+
# - Inode changed = file was atomically replaced (temp + rename)
|
|
95
|
+
# - Size < position = file was truncated
|
|
96
|
+
if current_inode != old_inode or current_size < position:
|
|
97
|
+
# File was replaced - start from beginning
|
|
98
|
+
position = 0
|
|
99
|
+
|
|
100
|
+
with open(log_path, "r") as f:
|
|
101
|
+
f.seek(position)
|
|
102
|
+
new_content = f.read()
|
|
103
|
+
new_position = f.tell()
|
|
104
|
+
|
|
105
|
+
self._file_positions[log_path] = new_position
|
|
106
|
+
self._file_inodes[log_path] = current_inode
|
|
107
|
+
|
|
108
|
+
# Track consecutive raw text lines to group them
|
|
109
|
+
raw_text_lines: list[str] = []
|
|
110
|
+
|
|
111
|
+
def flush_raw_text() -> None:
|
|
112
|
+
"""Flush accumulated raw text lines as a single event."""
|
|
113
|
+
if raw_text_lines:
|
|
114
|
+
event = {
|
|
115
|
+
"type": "raw_text",
|
|
116
|
+
"timestamp": None,
|
|
117
|
+
"part": {"text": "\n".join(raw_text_lines)},
|
|
118
|
+
}
|
|
119
|
+
events.append((source, event))
|
|
120
|
+
raw_text_lines.clear()
|
|
121
|
+
|
|
122
|
+
for line in new_content.splitlines():
|
|
123
|
+
line = line.strip()
|
|
124
|
+
if not line:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# Try to parse as JSON
|
|
128
|
+
if line.startswith("{"):
|
|
129
|
+
try:
|
|
130
|
+
event = json.loads(line)
|
|
131
|
+
# Flush any accumulated raw text first
|
|
132
|
+
flush_raw_text()
|
|
133
|
+
# Handle raw JSON outputs (no type/timestamp) as "additional_output"
|
|
134
|
+
if not event.get("type") or not event.get("timestamp"):
|
|
135
|
+
# Create a synthetic event for raw output
|
|
136
|
+
event = {
|
|
137
|
+
"type": "additional_output",
|
|
138
|
+
"timestamp": None,
|
|
139
|
+
"part": {"raw_data": event},
|
|
140
|
+
}
|
|
141
|
+
events.append((source, event))
|
|
142
|
+
except json.JSONDecodeError:
|
|
143
|
+
# JSON-like but malformed - treat as raw text
|
|
144
|
+
raw_text_lines.append(line)
|
|
145
|
+
else:
|
|
146
|
+
# Not JSON - accumulate as raw text
|
|
147
|
+
raw_text_lines.append(line)
|
|
148
|
+
|
|
149
|
+
# Flush any remaining raw text
|
|
150
|
+
flush_raw_text()
|
|
151
|
+
|
|
152
|
+
except (IOError, OSError):
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
return events
|
|
156
|
+
|
|
157
|
+
def start(self) -> None:
|
|
158
|
+
"""
|
|
159
|
+
Start watching - reads all existing log files.
|
|
160
|
+
"""
|
|
161
|
+
if self._running:
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
# Read existing content
|
|
165
|
+
for log_path in self._logs_dir.rglob("*.log"):
|
|
166
|
+
for source, event in self._read_new_lines(log_path):
|
|
167
|
+
self._event_queue.put((source, event))
|
|
168
|
+
|
|
169
|
+
self._running = True
|
|
170
|
+
|
|
171
|
+
def stop(self) -> None:
|
|
172
|
+
"""Stop watching."""
|
|
173
|
+
self._running = False
|
|
174
|
+
|
|
175
|
+
def poll(self) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Poll all log files for new content.
|
|
178
|
+
|
|
179
|
+
Should be called periodically from the UI update timer.
|
|
180
|
+
"""
|
|
181
|
+
if not self._running:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
for log_path in self._logs_dir.rglob("*.log"):
|
|
185
|
+
for source, event in self._read_new_lines(log_path):
|
|
186
|
+
self._event_queue.put((source, event))
|
|
187
|
+
|
|
188
|
+
def get_events(self) -> list[tuple[str, dict[str, Any]]]:
|
|
189
|
+
"""
|
|
190
|
+
Get all pending events from queue (non-blocking).
|
|
191
|
+
|
|
192
|
+
Returns
|
|
193
|
+
-------
|
|
194
|
+
list[tuple[str, dict[str, Any]]]
|
|
195
|
+
List of (source_name, event_dict) tuples.
|
|
196
|
+
"""
|
|
197
|
+
events: list[tuple[str, dict[str, Any]]] = []
|
|
198
|
+
while not self._event_queue.empty():
|
|
199
|
+
try:
|
|
200
|
+
events.append(self._event_queue.get_nowait())
|
|
201
|
+
except Exception:
|
|
202
|
+
break
|
|
203
|
+
return events
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def is_running(self) -> bool:
|
|
207
|
+
"""Whether watcher is currently running."""
|
|
208
|
+
return self._running
|
dlab/tui/models.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for TUI state management.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class LogEvent:
|
|
14
|
+
"""
|
|
15
|
+
Parsed log event with computed fields.
|
|
16
|
+
|
|
17
|
+
Attributes
|
|
18
|
+
----------
|
|
19
|
+
timestamp : int
|
|
20
|
+
Timestamp in milliseconds.
|
|
21
|
+
dt : datetime
|
|
22
|
+
Datetime object.
|
|
23
|
+
event_type : str
|
|
24
|
+
Event type (step_start, step_finish, text, tool_use).
|
|
25
|
+
source : str
|
|
26
|
+
Source agent/log name.
|
|
27
|
+
description : str
|
|
28
|
+
Human-readable description (truncated for display).
|
|
29
|
+
raw : dict[str, Any]
|
|
30
|
+
Original raw JSON data.
|
|
31
|
+
cost : float
|
|
32
|
+
Cost for this event (from step_finish).
|
|
33
|
+
duration_ms : int | None
|
|
34
|
+
Duration in milliseconds if applicable.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
timestamp: int
|
|
38
|
+
dt: datetime
|
|
39
|
+
event_type: str
|
|
40
|
+
source: str
|
|
41
|
+
description: str
|
|
42
|
+
raw: dict[str, Any]
|
|
43
|
+
cost: float = 0.0
|
|
44
|
+
duration_ms: int | None = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def full_description(self) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Get full untruncated description from raw data.
|
|
50
|
+
|
|
51
|
+
Returns
|
|
52
|
+
-------
|
|
53
|
+
str
|
|
54
|
+
Full description without any truncation.
|
|
55
|
+
"""
|
|
56
|
+
if self.event_type == "step_start":
|
|
57
|
+
return "Step started"
|
|
58
|
+
|
|
59
|
+
if self.event_type == "step_finish":
|
|
60
|
+
part = self.raw.get("part", {})
|
|
61
|
+
reason = part.get("reason", "unknown")
|
|
62
|
+
return f"Step finished ({reason})"
|
|
63
|
+
|
|
64
|
+
if self.event_type == "text":
|
|
65
|
+
part = self.raw.get("part", {})
|
|
66
|
+
return part.get("text", "")
|
|
67
|
+
|
|
68
|
+
if self.event_type == "raw_text":
|
|
69
|
+
part = self.raw.get("part", {})
|
|
70
|
+
return part.get("text", "")
|
|
71
|
+
|
|
72
|
+
if self.event_type == "error":
|
|
73
|
+
error_data = self.raw.get("error", {})
|
|
74
|
+
error_name = error_data.get("name", "Error")
|
|
75
|
+
data = error_data.get("data", {})
|
|
76
|
+
message = data.get("message", "Unknown error")
|
|
77
|
+
status_code = data.get("statusCode", "")
|
|
78
|
+
response_body = data.get("responseBody", "")
|
|
79
|
+
|
|
80
|
+
description = f"[{error_name}]"
|
|
81
|
+
if status_code:
|
|
82
|
+
description += f" (status: {status_code})"
|
|
83
|
+
description += f"\n{message}"
|
|
84
|
+
if response_body:
|
|
85
|
+
description += f"\n--- response ---\n{response_body}"
|
|
86
|
+
return description
|
|
87
|
+
|
|
88
|
+
if self.event_type == "tool_use":
|
|
89
|
+
part = self.raw.get("part", {})
|
|
90
|
+
tool = part.get("tool", "unknown")
|
|
91
|
+
state = part.get("state", {})
|
|
92
|
+
status = state.get("status", "unknown")
|
|
93
|
+
input_data = state.get("input", {})
|
|
94
|
+
output = state.get("output", "")
|
|
95
|
+
|
|
96
|
+
if tool == "bash":
|
|
97
|
+
cmd = input_data.get("command", "")
|
|
98
|
+
desc = input_data.get("description", "")
|
|
99
|
+
description = f"bash: {desc or cmd}"
|
|
100
|
+
if output:
|
|
101
|
+
description += f"\n--- output ---\n{output}"
|
|
102
|
+
return description
|
|
103
|
+
|
|
104
|
+
if tool == "read":
|
|
105
|
+
filepath = input_data.get("filePath", "")
|
|
106
|
+
return f"read: {Path(filepath).name}"
|
|
107
|
+
|
|
108
|
+
if tool == "write":
|
|
109
|
+
filepath = input_data.get("filePath", "")
|
|
110
|
+
filename = Path(filepath).name
|
|
111
|
+
content = input_data.get("content", "")
|
|
112
|
+
description = f"write: {filename}"
|
|
113
|
+
if content:
|
|
114
|
+
description += f"\n--- content ---\n{content}"
|
|
115
|
+
return description
|
|
116
|
+
|
|
117
|
+
if tool == "edit":
|
|
118
|
+
filepath = input_data.get("filePath", "")
|
|
119
|
+
old_string = input_data.get("oldString", "")
|
|
120
|
+
new_string = input_data.get("newString", "")
|
|
121
|
+
description = f"edit: {Path(filepath).name}"
|
|
122
|
+
if old_string or new_string:
|
|
123
|
+
description += f"\n-{old_string}\n+{new_string}"
|
|
124
|
+
return description
|
|
125
|
+
|
|
126
|
+
if tool == "task":
|
|
127
|
+
subagent = input_data.get("subagent_type", "")
|
|
128
|
+
desc = input_data.get("description", "")
|
|
129
|
+
description = f"task: {subagent} - {desc}"
|
|
130
|
+
if output:
|
|
131
|
+
description += f"\n--- output ---\n{output}"
|
|
132
|
+
return description
|
|
133
|
+
|
|
134
|
+
if tool == "parallel-agents":
|
|
135
|
+
agent = input_data.get("agent", "")
|
|
136
|
+
prompts = input_data.get("prompts", [])
|
|
137
|
+
description = f"parallel-agents: {agent} x{len(prompts)}"
|
|
138
|
+
if output:
|
|
139
|
+
description += f"\n--- output ---\n{output}"
|
|
140
|
+
return description
|
|
141
|
+
|
|
142
|
+
# Generic tool - show everything for debugging
|
|
143
|
+
description = f"{tool} ({status})"
|
|
144
|
+
# Show input parameters
|
|
145
|
+
if input_data:
|
|
146
|
+
description += f"\n--- input ---\n"
|
|
147
|
+
for k, v in input_data.items():
|
|
148
|
+
description += f" {k}: {v}\n"
|
|
149
|
+
# Show error if present
|
|
150
|
+
error = state.get("error", "")
|
|
151
|
+
if error:
|
|
152
|
+
description += f"\n--- error ---\n{error}"
|
|
153
|
+
# Show output if present
|
|
154
|
+
if output:
|
|
155
|
+
description += f"\n--- output ---\n{output}"
|
|
156
|
+
return description
|
|
157
|
+
|
|
158
|
+
return self.description
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def from_raw(cls, raw: dict[str, Any], source: str) -> "LogEvent":
|
|
162
|
+
"""
|
|
163
|
+
Create LogEvent from raw JSON event data.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
raw : dict[str, Any]
|
|
168
|
+
Raw JSON event from log file.
|
|
169
|
+
source : str
|
|
170
|
+
Source name for this event.
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
LogEvent
|
|
175
|
+
Parsed event.
|
|
176
|
+
"""
|
|
177
|
+
timestamp = raw.get("timestamp")
|
|
178
|
+
# Handle None timestamp (for additional_output events)
|
|
179
|
+
if timestamp is None:
|
|
180
|
+
timestamp = 0 # Will be displayed specially
|
|
181
|
+
dt = datetime.min # Sentinel — no real timestamp, won't affect duration
|
|
182
|
+
else:
|
|
183
|
+
dt = datetime.fromtimestamp(timestamp / 1000)
|
|
184
|
+
event_type = raw.get("type", "unknown")
|
|
185
|
+
part = raw.get("part", {})
|
|
186
|
+
|
|
187
|
+
# Build description based on event type
|
|
188
|
+
description = ""
|
|
189
|
+
cost = 0.0
|
|
190
|
+
duration_ms = None
|
|
191
|
+
|
|
192
|
+
if event_type == "additional_output":
|
|
193
|
+
# Raw JSON output from tool/script (not a proper OpenCode event)
|
|
194
|
+
raw_data = part.get("raw_data", {})
|
|
195
|
+
description = f"[output] {json.dumps(raw_data)}"
|
|
196
|
+
|
|
197
|
+
elif event_type == "raw_text":
|
|
198
|
+
# Raw text output (non-JSON lines from log)
|
|
199
|
+
description = part.get("text", "")
|
|
200
|
+
|
|
201
|
+
elif event_type == "step_start":
|
|
202
|
+
description = "Step started"
|
|
203
|
+
|
|
204
|
+
elif event_type == "step_finish":
|
|
205
|
+
reason = part.get("reason", "unknown")
|
|
206
|
+
description = f"Step finished ({reason})"
|
|
207
|
+
cost = part.get("cost", 0.0)
|
|
208
|
+
|
|
209
|
+
elif event_type == "text":
|
|
210
|
+
text = part.get("text", "")
|
|
211
|
+
description = text
|
|
212
|
+
|
|
213
|
+
elif event_type == "error":
|
|
214
|
+
error_data = raw.get("error", {})
|
|
215
|
+
error_name = error_data.get("name", "Error")
|
|
216
|
+
data = error_data.get("data", {})
|
|
217
|
+
message = data.get("message", "Unknown error")
|
|
218
|
+
status_code = data.get("statusCode", "")
|
|
219
|
+
response_body = data.get("responseBody", "")
|
|
220
|
+
|
|
221
|
+
description = f"[{error_name}]"
|
|
222
|
+
if status_code:
|
|
223
|
+
description += f" (status: {status_code})"
|
|
224
|
+
description += f"\n{message}"
|
|
225
|
+
if response_body:
|
|
226
|
+
description += f"\n--- response ---\n{response_body}"
|
|
227
|
+
|
|
228
|
+
elif event_type == "tool_use":
|
|
229
|
+
tool = part.get("tool", "unknown")
|
|
230
|
+
state = part.get("state", {})
|
|
231
|
+
status = state.get("status", "unknown")
|
|
232
|
+
input_data = state.get("input", {})
|
|
233
|
+
output = state.get("output", "")
|
|
234
|
+
|
|
235
|
+
if tool == "bash":
|
|
236
|
+
cmd = input_data.get("command", "")
|
|
237
|
+
desc = input_data.get("description", "")
|
|
238
|
+
description = f"bash: {desc or cmd}"
|
|
239
|
+
if output:
|
|
240
|
+
description += f"\n--- output ---\n{output}"
|
|
241
|
+
elif tool == "read":
|
|
242
|
+
filepath = input_data.get("filePath", "")
|
|
243
|
+
description = f"read: {Path(filepath).name}"
|
|
244
|
+
elif tool == "write":
|
|
245
|
+
filepath = input_data.get("filePath", "")
|
|
246
|
+
filename = Path(filepath).name
|
|
247
|
+
content = input_data.get("content", "")
|
|
248
|
+
description = f"write: {filename}"
|
|
249
|
+
if content:
|
|
250
|
+
description += f"\n--- content ---\n{content}"
|
|
251
|
+
elif tool == "edit":
|
|
252
|
+
filepath = input_data.get("filePath", "")
|
|
253
|
+
old_string = input_data.get("oldString", "")
|
|
254
|
+
new_string = input_data.get("newString", "")
|
|
255
|
+
description = f"edit: {Path(filepath).name}"
|
|
256
|
+
if old_string or new_string:
|
|
257
|
+
description += f"\n-{old_string}\n+{new_string}"
|
|
258
|
+
elif tool == "task":
|
|
259
|
+
subagent = input_data.get("subagent_type", "")
|
|
260
|
+
desc = input_data.get("description", "")
|
|
261
|
+
description = f"task: {subagent} - {desc}"
|
|
262
|
+
if output:
|
|
263
|
+
description += f"\n--- output ---\n{output}"
|
|
264
|
+
elif tool == "parallel-agents":
|
|
265
|
+
agent = input_data.get("agent", "")
|
|
266
|
+
prompts = input_data.get("prompts", [])
|
|
267
|
+
description = f"parallel-agents: {agent} x{len(prompts)}"
|
|
268
|
+
if output:
|
|
269
|
+
description += f"\n--- output ---\n{output}"
|
|
270
|
+
else:
|
|
271
|
+
# Generic tool handling - show tool name, status, and any output/error
|
|
272
|
+
description = f"{tool} ({status})"
|
|
273
|
+
# Show input parameters
|
|
274
|
+
if input_data:
|
|
275
|
+
description += f"\n--- input ---\n"
|
|
276
|
+
for k, v in input_data.items():
|
|
277
|
+
description += f" {k}: {v}\n"
|
|
278
|
+
# Show error if present
|
|
279
|
+
error = state.get("error", "")
|
|
280
|
+
if error:
|
|
281
|
+
description += f"\n--- error ---\n{error}"
|
|
282
|
+
# Show output if present
|
|
283
|
+
if output:
|
|
284
|
+
description += f"\n--- output ---\n{output}"
|
|
285
|
+
|
|
286
|
+
# Extract timing
|
|
287
|
+
time_data = state.get("time", {})
|
|
288
|
+
if time_data:
|
|
289
|
+
start = time_data.get("start")
|
|
290
|
+
end = time_data.get("end")
|
|
291
|
+
if start and end:
|
|
292
|
+
duration_ms = end - start
|
|
293
|
+
|
|
294
|
+
return cls(
|
|
295
|
+
timestamp=timestamp,
|
|
296
|
+
dt=dt,
|
|
297
|
+
event_type=event_type,
|
|
298
|
+
source=source,
|
|
299
|
+
description=description,
|
|
300
|
+
raw=raw,
|
|
301
|
+
cost=cost,
|
|
302
|
+
duration_ms=duration_ms,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@dataclass
|
|
307
|
+
class AgentState:
|
|
308
|
+
"""
|
|
309
|
+
State tracking for a single agent/source.
|
|
310
|
+
|
|
311
|
+
Attributes
|
|
312
|
+
----------
|
|
313
|
+
name : str
|
|
314
|
+
Agent/source name.
|
|
315
|
+
is_running : bool
|
|
316
|
+
Whether the agent is still running.
|
|
317
|
+
events : list[LogEvent]
|
|
318
|
+
List of events for this agent.
|
|
319
|
+
total_cost : float
|
|
320
|
+
Accumulated cost.
|
|
321
|
+
start_time : datetime | None
|
|
322
|
+
Start time of first event.
|
|
323
|
+
end_time : datetime | None
|
|
324
|
+
End time of last event.
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
name: str
|
|
328
|
+
is_running: bool = True
|
|
329
|
+
events: list[LogEvent] = field(default_factory=list)
|
|
330
|
+
total_cost: float = 0.0
|
|
331
|
+
start_time: datetime | None = None
|
|
332
|
+
end_time: datetime | None = None
|
|
333
|
+
|
|
334
|
+
def add_event(self, event: LogEvent) -> bool:
|
|
335
|
+
"""
|
|
336
|
+
Add event and update computed state.
|
|
337
|
+
|
|
338
|
+
Deduplicates events based on timestamp to prevent duplicates
|
|
339
|
+
from watchdog firing multiple events.
|
|
340
|
+
|
|
341
|
+
Parameters
|
|
342
|
+
----------
|
|
343
|
+
event : LogEvent
|
|
344
|
+
Event to add.
|
|
345
|
+
|
|
346
|
+
Returns
|
|
347
|
+
-------
|
|
348
|
+
bool
|
|
349
|
+
True if event was added, False if it was a duplicate.
|
|
350
|
+
"""
|
|
351
|
+
# Deduplicate based on timestamp (events with same timestamp are duplicates)
|
|
352
|
+
# Skip deduplication for timestamp=0 events (raw_text, additional_output)
|
|
353
|
+
if event.timestamp > 0 and any(e.timestamp == event.timestamp for e in self.events):
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
self.events.append(event)
|
|
357
|
+
self.total_cost += event.cost
|
|
358
|
+
|
|
359
|
+
# Only update timing from events with real timestamps (not raw text / additional_output)
|
|
360
|
+
if event.timestamp > 0:
|
|
361
|
+
if self.start_time is None or event.dt < self.start_time:
|
|
362
|
+
self.start_time = event.dt
|
|
363
|
+
if self.end_time is None or event.dt > self.end_time:
|
|
364
|
+
self.end_time = event.dt
|
|
365
|
+
|
|
366
|
+
return True
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
@dataclass
|
|
370
|
+
class SessionState:
|
|
371
|
+
"""
|
|
372
|
+
Overall session state.
|
|
373
|
+
|
|
374
|
+
Attributes
|
|
375
|
+
----------
|
|
376
|
+
work_dir : Path
|
|
377
|
+
Path to work directory.
|
|
378
|
+
agents : dict[str, AgentState]
|
|
379
|
+
Agent states keyed by name.
|
|
380
|
+
global_start_ts : int | None
|
|
381
|
+
Earliest timestamp across all agents.
|
|
382
|
+
is_job_running : bool
|
|
383
|
+
Whether the job is still running.
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
work_dir: Path
|
|
387
|
+
agents: dict[str, AgentState] = field(default_factory=dict)
|
|
388
|
+
global_start_ts: int | None = None
|
|
389
|
+
is_job_running: bool = True
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def total_cost(self) -> float:
|
|
393
|
+
"""Sum of all agent costs."""
|
|
394
|
+
return sum(a.total_cost for a in self.agents.values())
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def duration_seconds(self) -> float:
|
|
398
|
+
"""
|
|
399
|
+
Duration from first to last timestamp in logs.
|
|
400
|
+
|
|
401
|
+
NEVER uses datetime.now() - duration is purely derived from
|
|
402
|
+
log timestamps so the view is identical whether viewed live
|
|
403
|
+
or a year later.
|
|
404
|
+
"""
|
|
405
|
+
if self.global_start_ts is None:
|
|
406
|
+
return 0.0
|
|
407
|
+
|
|
408
|
+
start_dt = datetime.fromtimestamp(self.global_start_ts / 1000)
|
|
409
|
+
|
|
410
|
+
# Find latest end time from all agents (from log timestamps only)
|
|
411
|
+
end_times = [
|
|
412
|
+
a.end_time for a in self.agents.values() if a.end_time is not None
|
|
413
|
+
]
|
|
414
|
+
if end_times:
|
|
415
|
+
end_dt = max(end_times)
|
|
416
|
+
else:
|
|
417
|
+
# No events yet - duration is 0
|
|
418
|
+
return 0.0
|
|
419
|
+
|
|
420
|
+
return (end_dt - start_dt).total_seconds()
|
|
421
|
+
|
|
422
|
+
def get_or_create_agent(self, name: str) -> AgentState:
|
|
423
|
+
"""
|
|
424
|
+
Get existing agent state or create new one.
|
|
425
|
+
|
|
426
|
+
Parameters
|
|
427
|
+
----------
|
|
428
|
+
name : str
|
|
429
|
+
Agent name.
|
|
430
|
+
|
|
431
|
+
Returns
|
|
432
|
+
-------
|
|
433
|
+
AgentState
|
|
434
|
+
Agent state.
|
|
435
|
+
"""
|
|
436
|
+
if name not in self.agents:
|
|
437
|
+
self.agents[name] = AgentState(name=name)
|
|
438
|
+
return self.agents[name]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TUI widgets for dlab connect.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dlab.tui.widgets.agent_list import AgentSelector
|
|
6
|
+
from dlab.tui.widgets.log_view import LogView
|
|
7
|
+
from dlab.tui.widgets.status_bar import StatusBar
|
|
8
|
+
from dlab.tui.widgets.artifacts_pane import ArtifactList, FileViewer
|
|
9
|
+
from dlab.tui.widgets.search_popup import SearchPopup
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AgentSelector",
|
|
13
|
+
"LogView",
|
|
14
|
+
"StatusBar",
|
|
15
|
+
"ArtifactList",
|
|
16
|
+
"FileViewer",
|
|
17
|
+
"SearchPopup",
|
|
18
|
+
]
|