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/timeline.py
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Timeline parsing and visualization for dlab sessions.
|
|
3
|
+
|
|
4
|
+
This module parses OpenCode log files and constructs execution timelines
|
|
5
|
+
with Gantt chart visualization showing parallel agent execution.
|
|
6
|
+
|
|
7
|
+
Supports both completed and running jobs - running jobs show agents
|
|
8
|
+
with "RUNNING" status and extend to current time in the Gantt chart.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ms_to_datetime(timestamp_ms: int) -> datetime:
|
|
20
|
+
"""Convert millisecond timestamp to datetime."""
|
|
21
|
+
return datetime.fromtimestamp(timestamp_ms / 1000)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_log_complete(log_path: Path) -> bool:
|
|
25
|
+
"""
|
|
26
|
+
Check if a log file represents a completed run.
|
|
27
|
+
|
|
28
|
+
A log is complete if:
|
|
29
|
+
- Its last step_finish event has reason: "stop" or "error", OR
|
|
30
|
+
- It contains an "error" event (job crashed/terminated)
|
|
31
|
+
|
|
32
|
+
Running logs have step_finish events with reason: "tool-calls".
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
log_path : Path
|
|
37
|
+
Path to the log file.
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
bool
|
|
42
|
+
True if the log shows a completed run, False if still running.
|
|
43
|
+
"""
|
|
44
|
+
if not log_path.exists():
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
last_step_finish: dict[str, Any] | None = None
|
|
48
|
+
has_error: bool = False
|
|
49
|
+
|
|
50
|
+
with open(log_path, "r") as f:
|
|
51
|
+
for line in f:
|
|
52
|
+
line = line.strip()
|
|
53
|
+
if not line or line.startswith("[STDERR]"):
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(line)
|
|
57
|
+
event_type = data.get("type")
|
|
58
|
+
if event_type == "step_finish":
|
|
59
|
+
last_step_finish = data
|
|
60
|
+
elif event_type == "error":
|
|
61
|
+
has_error = True
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
# Error event means job terminated (crashed)
|
|
66
|
+
if has_error:
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
if last_step_finish is None:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
reason = last_step_finish.get("part", {}).get("reason", "")
|
|
73
|
+
return reason in ("stop", "error")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def natural_sort_key(name: str) -> tuple:
|
|
77
|
+
"""
|
|
78
|
+
Sort key for natural ordering.
|
|
79
|
+
|
|
80
|
+
Orders: main first, task subagents, instance-1..N, consolidator last.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
name : str
|
|
85
|
+
Log source name to sort.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
tuple
|
|
90
|
+
Sort key tuple.
|
|
91
|
+
"""
|
|
92
|
+
if name == "main":
|
|
93
|
+
return (0, 0, "")
|
|
94
|
+
if name == "consolidator":
|
|
95
|
+
return (4, 0, "")
|
|
96
|
+
|
|
97
|
+
# Check for task subagent pattern (e.g., "popo-poet (task)")
|
|
98
|
+
if name.endswith(" (task)"):
|
|
99
|
+
return (1, 0, name)
|
|
100
|
+
|
|
101
|
+
# Check for instance-N pattern
|
|
102
|
+
match = re.match(r"instance-(\d+)", name)
|
|
103
|
+
if match:
|
|
104
|
+
return (2, int(match.group(1)), "")
|
|
105
|
+
|
|
106
|
+
# Check for parallel run directories (e.g., modeler-parallel-run-123456)
|
|
107
|
+
match = re.match(r"(.+)-parallel-run-(\d+)", name)
|
|
108
|
+
if match:
|
|
109
|
+
return (3, int(match.group(2)), match.group(1))
|
|
110
|
+
|
|
111
|
+
# Default: alphabetical
|
|
112
|
+
return (3, 0, name)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def discover_agents(opencode_dir: Path) -> set[str]:
|
|
116
|
+
"""
|
|
117
|
+
Discover agent names from .opencode/agents/*.md files.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
opencode_dir : Path
|
|
122
|
+
Path to .opencode directory.
|
|
123
|
+
|
|
124
|
+
Returns
|
|
125
|
+
-------
|
|
126
|
+
set[str]
|
|
127
|
+
Set of agent names (filenames without .md).
|
|
128
|
+
"""
|
|
129
|
+
agents_dir = opencode_dir / "agents"
|
|
130
|
+
if not agents_dir.exists():
|
|
131
|
+
return set()
|
|
132
|
+
|
|
133
|
+
return {f.stem for f in agents_dir.glob("*.md")}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def format_duration(ms: int) -> str:
|
|
137
|
+
"""
|
|
138
|
+
Format milliseconds as human-readable duration.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
ms : int
|
|
143
|
+
Duration in milliseconds.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
str
|
|
148
|
+
Human-readable duration string (e.g., "5.2s", "3.1m", "1.5h").
|
|
149
|
+
"""
|
|
150
|
+
seconds = ms / 1000
|
|
151
|
+
if seconds < 60:
|
|
152
|
+
return f"{seconds:.1f}s"
|
|
153
|
+
minutes = seconds / 60
|
|
154
|
+
if minutes < 60:
|
|
155
|
+
return f"{minutes:.1f}m"
|
|
156
|
+
hours = minutes / 60
|
|
157
|
+
return f"{hours:.1f}h"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_log_file(log_path: Path) -> list[dict[str, Any]]:
|
|
161
|
+
"""
|
|
162
|
+
Parse a single log file and extract events.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
log_path : Path
|
|
167
|
+
Path to the log file.
|
|
168
|
+
|
|
169
|
+
Returns
|
|
170
|
+
-------
|
|
171
|
+
list[dict[str, Any]]
|
|
172
|
+
List of parsed events with timestamp, type, and description.
|
|
173
|
+
"""
|
|
174
|
+
events = []
|
|
175
|
+
|
|
176
|
+
with open(log_path, "r") as f:
|
|
177
|
+
for line in f:
|
|
178
|
+
line = line.strip()
|
|
179
|
+
if not line:
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
# Skip non-JSON lines (like [STDERR] prefixed lines)
|
|
183
|
+
if line.startswith("[STDERR]"):
|
|
184
|
+
continue
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
data = json.loads(line)
|
|
188
|
+
except json.JSONDecodeError:
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
timestamp = data.get("timestamp")
|
|
192
|
+
event_type = data.get("type")
|
|
193
|
+
part = data.get("part", {})
|
|
194
|
+
|
|
195
|
+
if not timestamp or not event_type:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
event: dict[str, Any] = {
|
|
199
|
+
"timestamp": timestamp,
|
|
200
|
+
"datetime": ms_to_datetime(timestamp),
|
|
201
|
+
"type": event_type,
|
|
202
|
+
"source": log_path.stem,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# Extract relevant details based on event type
|
|
206
|
+
if event_type == "step_start":
|
|
207
|
+
event["description"] = "Step started"
|
|
208
|
+
|
|
209
|
+
elif event_type == "step_finish":
|
|
210
|
+
event["description"] = f"Step finished ({part.get('reason', 'unknown')})"
|
|
211
|
+
cost = part.get("cost", 0)
|
|
212
|
+
tokens = part.get("tokens", {})
|
|
213
|
+
if cost:
|
|
214
|
+
event["cost"] = cost
|
|
215
|
+
if tokens:
|
|
216
|
+
event["tokens"] = tokens
|
|
217
|
+
|
|
218
|
+
elif event_type == "text":
|
|
219
|
+
text = part.get("text", "")
|
|
220
|
+
# Truncate long text
|
|
221
|
+
if len(text) > 100:
|
|
222
|
+
text = text[:100] + "..."
|
|
223
|
+
event["description"] = f"Text: {text}"
|
|
224
|
+
|
|
225
|
+
elif event_type == "tool_use":
|
|
226
|
+
tool = part.get("tool", "unknown")
|
|
227
|
+
state = part.get("state", {})
|
|
228
|
+
status = state.get("status", "unknown")
|
|
229
|
+
input_data = state.get("input", {})
|
|
230
|
+
|
|
231
|
+
# Get tool-specific description
|
|
232
|
+
if tool == "bash":
|
|
233
|
+
cmd = input_data.get("command", "")
|
|
234
|
+
desc = input_data.get("description", "")
|
|
235
|
+
if len(cmd) > 50:
|
|
236
|
+
cmd = cmd[:50] + "..."
|
|
237
|
+
event["description"] = f"Tool: {tool} ({status}) - {desc or cmd}"
|
|
238
|
+
elif tool == "read":
|
|
239
|
+
filepath = input_data.get("filePath", "")
|
|
240
|
+
event["description"] = f"Tool: {tool} ({status}) - {Path(filepath).name}"
|
|
241
|
+
elif tool == "write":
|
|
242
|
+
filepath = input_data.get("filePath", "")
|
|
243
|
+
event["description"] = f"Tool: {tool} ({status}) - {Path(filepath).name}"
|
|
244
|
+
elif tool == "task":
|
|
245
|
+
subagent = input_data.get("subagent_type", "")
|
|
246
|
+
desc = input_data.get("description", "")
|
|
247
|
+
event["description"] = f"Tool: {tool} ({status}) - {subagent}: {desc}"
|
|
248
|
+
# Store task subagent details for virtual agent creation
|
|
249
|
+
if subagent and status == "completed":
|
|
250
|
+
event["task_subagent"] = subagent
|
|
251
|
+
event["task_output"] = state.get("output", "")
|
|
252
|
+
task_time = state.get("time", {})
|
|
253
|
+
if task_time:
|
|
254
|
+
event["task_start_ts"] = task_time.get("start")
|
|
255
|
+
event["task_end_ts"] = task_time.get("end")
|
|
256
|
+
# Mark as idle period for the calling agent
|
|
257
|
+
event["idle_period"] = (task_time.get("start"), task_time.get("end"))
|
|
258
|
+
elif tool == "parallel-agents":
|
|
259
|
+
agent = input_data.get("agent", "")
|
|
260
|
+
prompts = input_data.get("prompts", [])
|
|
261
|
+
event["description"] = f"Tool: {tool} ({status}) - {agent} x{len(prompts)}"
|
|
262
|
+
# Mark as idle period for the calling agent
|
|
263
|
+
tool_time = state.get("time", {})
|
|
264
|
+
if tool_time and status == "completed":
|
|
265
|
+
event["idle_period"] = (tool_time.get("start"), tool_time.get("end"))
|
|
266
|
+
else:
|
|
267
|
+
event["description"] = f"Tool: {tool} ({status})"
|
|
268
|
+
|
|
269
|
+
# Extract timing if available
|
|
270
|
+
time_data = state.get("time", {})
|
|
271
|
+
if time_data:
|
|
272
|
+
start = time_data.get("start")
|
|
273
|
+
end = time_data.get("end")
|
|
274
|
+
if start and end:
|
|
275
|
+
event["duration_ms"] = end - start
|
|
276
|
+
|
|
277
|
+
events.append(event)
|
|
278
|
+
|
|
279
|
+
return events
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def build_timeline(
|
|
283
|
+
logs_dir: Path,
|
|
284
|
+
known_agents: set[str] | None = None,
|
|
285
|
+
is_running: bool = False,
|
|
286
|
+
) -> dict[str, Any]:
|
|
287
|
+
"""
|
|
288
|
+
Build a complete timeline from all log files in a directory.
|
|
289
|
+
|
|
290
|
+
Parameters
|
|
291
|
+
----------
|
|
292
|
+
logs_dir : Path
|
|
293
|
+
Directory containing log files (searched recursively).
|
|
294
|
+
known_agents : set[str] | None
|
|
295
|
+
Optional set of known agent names for task subagent detection.
|
|
296
|
+
is_running : bool
|
|
297
|
+
If True, the job is still running and some agents may not have finished.
|
|
298
|
+
|
|
299
|
+
Returns
|
|
300
|
+
-------
|
|
301
|
+
dict[str, Any]
|
|
302
|
+
Timeline data with events, file_summaries, total_events, and is_running.
|
|
303
|
+
"""
|
|
304
|
+
all_events: list[dict[str, Any]] = []
|
|
305
|
+
file_summaries: dict[str, dict[str, Any]] = {}
|
|
306
|
+
task_subagents: list[dict[str, Any]] = [] # Collect task subagent info
|
|
307
|
+
running_sources: set[str] = set() # Track which sources are still running
|
|
308
|
+
idle_periods_by_source: dict[str, list[tuple[int, int]]] = {} # Track idle periods
|
|
309
|
+
|
|
310
|
+
# Current time for running agents (in ms)
|
|
311
|
+
now_ms: int = int(datetime.now().timestamp() * 1000)
|
|
312
|
+
|
|
313
|
+
# Find all log files recursively
|
|
314
|
+
log_files = sorted(logs_dir.rglob("*.log"))
|
|
315
|
+
|
|
316
|
+
if not log_files:
|
|
317
|
+
print(f"No .log files found in {logs_dir}", file=sys.stderr)
|
|
318
|
+
return {}
|
|
319
|
+
|
|
320
|
+
# Parse each log file
|
|
321
|
+
for log_file in log_files:
|
|
322
|
+
events = parse_log_file(log_file)
|
|
323
|
+
|
|
324
|
+
# Use relative path from logs_dir as source name
|
|
325
|
+
rel_path = log_file.relative_to(logs_dir)
|
|
326
|
+
# For nested files like "modeler-parallel-run-123/instance-1.log", use full path
|
|
327
|
+
# For top-level files like "main.log", use "main"
|
|
328
|
+
if len(rel_path.parts) > 1:
|
|
329
|
+
# Nested: prepend parent dir name for context
|
|
330
|
+
source_name = f"{rel_path.parent.name}/{rel_path.stem}"
|
|
331
|
+
else:
|
|
332
|
+
source_name = rel_path.stem
|
|
333
|
+
|
|
334
|
+
# Check if this log file is complete (only relevant if job is running)
|
|
335
|
+
log_complete: bool = True
|
|
336
|
+
if is_running:
|
|
337
|
+
log_complete = is_log_complete(log_file)
|
|
338
|
+
if not log_complete:
|
|
339
|
+
running_sources.add(source_name)
|
|
340
|
+
|
|
341
|
+
# Update source in all events and collect idle periods
|
|
342
|
+
for e in events:
|
|
343
|
+
e["source"] = source_name
|
|
344
|
+
# Collect idle periods (when waiting on task/parallel-agents)
|
|
345
|
+
if "idle_period" in e:
|
|
346
|
+
if source_name not in idle_periods_by_source:
|
|
347
|
+
idle_periods_by_source[source_name] = []
|
|
348
|
+
idle_periods_by_source[source_name].append(e["idle_period"])
|
|
349
|
+
|
|
350
|
+
all_events.extend(events)
|
|
351
|
+
|
|
352
|
+
if events:
|
|
353
|
+
start_time = min(e["timestamp"] for e in events)
|
|
354
|
+
end_time = max(e["timestamp"] for e in events)
|
|
355
|
+
|
|
356
|
+
# For running logs, use current time as end
|
|
357
|
+
if not log_complete:
|
|
358
|
+
end_time = now_ms
|
|
359
|
+
|
|
360
|
+
duration = end_time - start_time
|
|
361
|
+
|
|
362
|
+
# Count events by type
|
|
363
|
+
type_counts: dict[str, int] = {}
|
|
364
|
+
for e in events:
|
|
365
|
+
t = e["type"]
|
|
366
|
+
type_counts[t] = type_counts.get(t, 0) + 1
|
|
367
|
+
|
|
368
|
+
# Sum costs
|
|
369
|
+
total_cost = sum(e.get("cost", 0) for e in events)
|
|
370
|
+
|
|
371
|
+
file_summaries[source_name] = {
|
|
372
|
+
"start": ms_to_datetime(start_time),
|
|
373
|
+
"end": ms_to_datetime(end_time),
|
|
374
|
+
"start_ms": start_time,
|
|
375
|
+
"end_ms": end_time,
|
|
376
|
+
"duration_ms": duration,
|
|
377
|
+
"event_count": len(events),
|
|
378
|
+
"type_counts": type_counts,
|
|
379
|
+
"total_cost": total_cost,
|
|
380
|
+
"is_running": not log_complete,
|
|
381
|
+
"idle_periods": idle_periods_by_source.get(source_name, []),
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
# Sort all events by timestamp
|
|
385
|
+
all_events.sort(key=lambda e: e["timestamp"])
|
|
386
|
+
|
|
387
|
+
# Collect task subagent info from events
|
|
388
|
+
if known_agents:
|
|
389
|
+
for e in all_events:
|
|
390
|
+
if "task_subagent" in e:
|
|
391
|
+
subagent_name = e["task_subagent"]
|
|
392
|
+
if subagent_name in known_agents:
|
|
393
|
+
task_subagents.append({
|
|
394
|
+
"name": subagent_name,
|
|
395
|
+
"caller": e["source"],
|
|
396
|
+
"start_ts": e.get("task_start_ts"),
|
|
397
|
+
"end_ts": e.get("task_end_ts"),
|
|
398
|
+
"output": e.get("task_output", ""),
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
# Create virtual agent entries for task subagents
|
|
402
|
+
for task in task_subagents:
|
|
403
|
+
if not task["start_ts"] or not task["end_ts"]:
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
source_name = f"{task['name']} (task)"
|
|
407
|
+
start_ts = task["start_ts"]
|
|
408
|
+
end_ts = task["end_ts"]
|
|
409
|
+
duration = end_ts - start_ts
|
|
410
|
+
|
|
411
|
+
# Create synthetic events for this task subagent
|
|
412
|
+
output_preview = task["output"]
|
|
413
|
+
if len(output_preview) > 100:
|
|
414
|
+
output_preview = output_preview[:100] + "..."
|
|
415
|
+
# Clean up newlines for display
|
|
416
|
+
output_preview = output_preview.replace("\n", " ").strip()
|
|
417
|
+
|
|
418
|
+
start_event: dict[str, Any] = {
|
|
419
|
+
"timestamp": start_ts,
|
|
420
|
+
"datetime": ms_to_datetime(start_ts),
|
|
421
|
+
"type": "task_start",
|
|
422
|
+
"source": source_name,
|
|
423
|
+
"description": f"Spawned by {task['caller']}",
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
end_event: dict[str, Any] = {
|
|
427
|
+
"timestamp": end_ts,
|
|
428
|
+
"datetime": ms_to_datetime(end_ts),
|
|
429
|
+
"type": "task_finish",
|
|
430
|
+
"source": source_name,
|
|
431
|
+
"description": f"Output: {output_preview}" if output_preview else "Completed",
|
|
432
|
+
"duration_ms": duration,
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
all_events.extend([start_event, end_event])
|
|
436
|
+
|
|
437
|
+
# Add file summary for task subagent
|
|
438
|
+
file_summaries[source_name] = {
|
|
439
|
+
"start": ms_to_datetime(start_ts),
|
|
440
|
+
"end": ms_to_datetime(end_ts),
|
|
441
|
+
"duration_ms": duration,
|
|
442
|
+
"event_count": 2,
|
|
443
|
+
"type_counts": {"task_start": 1, "task_finish": 1},
|
|
444
|
+
"total_cost": 0, # Cost is tracked in the calling agent
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
# Re-sort events after adding task subagent events
|
|
448
|
+
all_events.sort(key=lambda e: e["timestamp"])
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
"events": all_events,
|
|
452
|
+
"file_summaries": file_summaries,
|
|
453
|
+
"total_events": len(all_events),
|
|
454
|
+
"is_running": is_running,
|
|
455
|
+
"running_sources": running_sources,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def print_timeline(timeline: dict[str, Any]) -> None:
|
|
460
|
+
"""
|
|
461
|
+
Print a formatted timeline to stdout.
|
|
462
|
+
|
|
463
|
+
Parameters
|
|
464
|
+
----------
|
|
465
|
+
timeline : dict[str, Any]
|
|
466
|
+
Timeline data from build_timeline().
|
|
467
|
+
"""
|
|
468
|
+
if not timeline:
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
events = timeline["events"]
|
|
472
|
+
file_summaries = timeline["file_summaries"]
|
|
473
|
+
is_running: bool = timeline.get("is_running", False)
|
|
474
|
+
|
|
475
|
+
# Print header with status
|
|
476
|
+
print("=" * 80)
|
|
477
|
+
if is_running:
|
|
478
|
+
print("LOG FILE SUMMARIES [JOB RUNNING]")
|
|
479
|
+
else:
|
|
480
|
+
print("LOG FILE SUMMARIES")
|
|
481
|
+
print("=" * 80)
|
|
482
|
+
|
|
483
|
+
# Find global start time for relative timing
|
|
484
|
+
global_start = min(s["start"] for s in file_summaries.values())
|
|
485
|
+
|
|
486
|
+
# Sort by natural order (main first, instance-1, instance-2, ..., consolidator last)
|
|
487
|
+
sorted_files = sorted(
|
|
488
|
+
file_summaries.items(),
|
|
489
|
+
key=lambda x: natural_sort_key(x[0].split("/")[-1] if "/" in x[0] else x[0])
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
for name, summary in sorted_files:
|
|
493
|
+
rel_start = (summary["start"] - global_start).total_seconds()
|
|
494
|
+
duration = format_duration(summary["duration_ms"])
|
|
495
|
+
cost = summary["total_cost"]
|
|
496
|
+
source_running: bool = summary.get("is_running", False)
|
|
497
|
+
|
|
498
|
+
status_suffix = " [RUNNING]" if source_running else ""
|
|
499
|
+
print(f"\n{name}:{status_suffix}")
|
|
500
|
+
print(f" Started: +{rel_start:.1f}s from global start")
|
|
501
|
+
print(f" Duration: {duration}{'*' if source_running else ''}")
|
|
502
|
+
print(f" Events: {summary['event_count']}")
|
|
503
|
+
print(f" Cost: ${cost:.4f}")
|
|
504
|
+
print(f" Types: {summary['type_counts']}")
|
|
505
|
+
|
|
506
|
+
# Print timeline
|
|
507
|
+
print("\n" + "=" * 80)
|
|
508
|
+
print("TIMELINE (first and last 20 events per source)")
|
|
509
|
+
print("=" * 80)
|
|
510
|
+
|
|
511
|
+
# Group events by source
|
|
512
|
+
by_source: dict[str, list[dict[str, Any]]] = {}
|
|
513
|
+
for e in events:
|
|
514
|
+
source = e["source"]
|
|
515
|
+
if source not in by_source:
|
|
516
|
+
by_source[source] = []
|
|
517
|
+
by_source[source].append(e)
|
|
518
|
+
|
|
519
|
+
global_start_ts = min(e["timestamp"] for e in events)
|
|
520
|
+
|
|
521
|
+
# Sort sources by natural order
|
|
522
|
+
sorted_sources = sorted(
|
|
523
|
+
by_source.keys(),
|
|
524
|
+
key=lambda x: natural_sort_key(x.split("/")[-1] if "/" in x else x)
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
for source in sorted_sources:
|
|
528
|
+
source_events = by_source[source]
|
|
529
|
+
print(f"\n--- {source} ---")
|
|
530
|
+
|
|
531
|
+
# Show first 20 and last 20 events
|
|
532
|
+
if len(source_events) <= 40:
|
|
533
|
+
display_events: list[dict[str, Any] | None] = source_events
|
|
534
|
+
else:
|
|
535
|
+
display_events = source_events[:20] + [None] + source_events[-20:]
|
|
536
|
+
|
|
537
|
+
for e in display_events:
|
|
538
|
+
if e is None:
|
|
539
|
+
print(f" ... ({len(source_events) - 40} events omitted) ...")
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
rel_time = (e["timestamp"] - global_start_ts) / 1000
|
|
543
|
+
time_str = f"+{rel_time:7.1f}s"
|
|
544
|
+
|
|
545
|
+
duration_str = ""
|
|
546
|
+
if "duration_ms" in e:
|
|
547
|
+
duration_str = f" [{format_duration(e['duration_ms'])}]"
|
|
548
|
+
|
|
549
|
+
print(f" {time_str} | {e['type']:12} | {e.get('description', '')}{duration_str}")
|
|
550
|
+
|
|
551
|
+
# Print overall summary
|
|
552
|
+
print("\n" + "=" * 80)
|
|
553
|
+
print("OVERALL SUMMARY")
|
|
554
|
+
print("=" * 80)
|
|
555
|
+
|
|
556
|
+
total_duration = max(e["timestamp"] for e in events) - min(e["timestamp"] for e in events)
|
|
557
|
+
total_cost = sum(s["total_cost"] for s in file_summaries.values())
|
|
558
|
+
|
|
559
|
+
print(f"Total duration: {format_duration(total_duration)}")
|
|
560
|
+
print(f"Total events: {timeline['total_events']}")
|
|
561
|
+
print(f"Total cost: ${total_cost:.4f}")
|
|
562
|
+
print(f"Log files: {len(file_summaries)}")
|
|
563
|
+
|
|
564
|
+
# Show parallel execution timeline
|
|
565
|
+
print("\n" + "=" * 80)
|
|
566
|
+
print("EXECUTION GANTT (visual)")
|
|
567
|
+
print("=" * 80)
|
|
568
|
+
|
|
569
|
+
# Normalize times to 0-50 character width
|
|
570
|
+
all_starts = [s["start"] for s in file_summaries.values()]
|
|
571
|
+
all_ends = [s["end"] for s in file_summaries.values()]
|
|
572
|
+
min_time = min(all_starts)
|
|
573
|
+
max_time = max(all_ends)
|
|
574
|
+
total_span = (max_time - min_time).total_seconds()
|
|
575
|
+
|
|
576
|
+
if total_span == 0:
|
|
577
|
+
total_span = 1
|
|
578
|
+
|
|
579
|
+
width = 50
|
|
580
|
+
|
|
581
|
+
# Calculate max name length for alignment
|
|
582
|
+
max_name_len = max(len(name) for name in file_summaries.keys())
|
|
583
|
+
name_width = min(max(max_name_len, 20), 45) # Between 20 and 45 chars
|
|
584
|
+
|
|
585
|
+
# Get global time range in ms for idle period calculations
|
|
586
|
+
min_time_ms = min(s["start_ms"] for s in file_summaries.values() if "start_ms" in s)
|
|
587
|
+
max_time_ms = max(s["end_ms"] for s in file_summaries.values() if "end_ms" in s)
|
|
588
|
+
total_span_ms = max_time_ms - min_time_ms
|
|
589
|
+
if total_span_ms == 0:
|
|
590
|
+
total_span_ms = 1
|
|
591
|
+
|
|
592
|
+
for name, summary in sorted_files:
|
|
593
|
+
rel_start = (summary["start"] - min_time).total_seconds()
|
|
594
|
+
rel_end = (summary["end"] - min_time).total_seconds()
|
|
595
|
+
source_running = summary.get("is_running", False)
|
|
596
|
+
idle_periods: list[tuple[int, int]] = summary.get("idle_periods", [])
|
|
597
|
+
|
|
598
|
+
start_pos = int((rel_start / total_span) * width)
|
|
599
|
+
end_pos = int((rel_end / total_span) * width)
|
|
600
|
+
|
|
601
|
+
# Build segmented bar with idle periods shown as grey
|
|
602
|
+
bar_chars: list[str] = [" "] * width
|
|
603
|
+
for i in range(start_pos, min(end_pos, width)):
|
|
604
|
+
bar_chars[i] = "█"
|
|
605
|
+
|
|
606
|
+
# Grey out idle periods
|
|
607
|
+
for idle_start, idle_end in idle_periods:
|
|
608
|
+
if idle_start is None or idle_end is None:
|
|
609
|
+
continue
|
|
610
|
+
# Convert idle period to bar positions
|
|
611
|
+
idle_start_rel = (idle_start - min_time_ms) / total_span_ms
|
|
612
|
+
idle_end_rel = (idle_end - min_time_ms) / total_span_ms
|
|
613
|
+
idle_start_pos = int(idle_start_rel * width)
|
|
614
|
+
idle_end_pos = int(idle_end_rel * width)
|
|
615
|
+
# Mark idle positions with grey
|
|
616
|
+
for i in range(max(idle_start_pos, start_pos), min(idle_end_pos + 1, end_pos, width)):
|
|
617
|
+
bar_chars[i] = "░"
|
|
618
|
+
|
|
619
|
+
# If source is still running, use different end character
|
|
620
|
+
if source_running and end_pos > 0 and end_pos <= width:
|
|
621
|
+
bar_chars[end_pos - 1] = "░"
|
|
622
|
+
|
|
623
|
+
bar = "".join(bar_chars)
|
|
624
|
+
duration = format_duration(summary["duration_ms"])
|
|
625
|
+
duration_suffix = "..." if source_running else ""
|
|
626
|
+
|
|
627
|
+
# Truncate long names
|
|
628
|
+
display_name = name if len(name) <= name_width else "..." + name[-(name_width - 3):]
|
|
629
|
+
print(f"{display_name:{name_width}} |{bar}| {duration}{duration_suffix}")
|
|
630
|
+
|
|
631
|
+
print(f"{'':{name_width}} |{'─' * width}|")
|
|
632
|
+
print(f"{'':{name_width}} 0s{' ' * (width - 10)}{format_duration(total_span * 1000):>8}")
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def run_timeline(work_dir: Path | None) -> int:
|
|
636
|
+
"""
|
|
637
|
+
Run timeline analysis on a work directory.
|
|
638
|
+
|
|
639
|
+
Parameters
|
|
640
|
+
----------
|
|
641
|
+
work_dir : Path | None
|
|
642
|
+
Path to work directory. If None, checks cwd for _opencode_logs.
|
|
643
|
+
|
|
644
|
+
Returns
|
|
645
|
+
-------
|
|
646
|
+
int
|
|
647
|
+
Exit code (0 success, 1 error).
|
|
648
|
+
"""
|
|
649
|
+
# Resolve logs directory and base directory
|
|
650
|
+
if work_dir is None:
|
|
651
|
+
# Check if _opencode_logs exists in cwd
|
|
652
|
+
cwd_logs = Path.cwd() / "_opencode_logs"
|
|
653
|
+
if cwd_logs.exists() and cwd_logs.is_dir():
|
|
654
|
+
logs_dir = cwd_logs
|
|
655
|
+
base_dir = Path.cwd()
|
|
656
|
+
else:
|
|
657
|
+
print(
|
|
658
|
+
"Error: No work directory specified and no _opencode_logs in current directory",
|
|
659
|
+
file=sys.stderr
|
|
660
|
+
)
|
|
661
|
+
return 1
|
|
662
|
+
else:
|
|
663
|
+
logs_dir = work_dir / "_opencode_logs"
|
|
664
|
+
base_dir = work_dir
|
|
665
|
+
if not logs_dir.exists():
|
|
666
|
+
print(f"Error: No _opencode_logs directory found in {work_dir}", file=sys.stderr)
|
|
667
|
+
return 1
|
|
668
|
+
|
|
669
|
+
# Try to find .opencode/agents for task subagent detection
|
|
670
|
+
opencode_dir = base_dir / ".opencode"
|
|
671
|
+
known_agents: set[str] = set()
|
|
672
|
+
if opencode_dir.exists():
|
|
673
|
+
known_agents = discover_agents(opencode_dir)
|
|
674
|
+
|
|
675
|
+
# Check if the job is still running by examining main.log
|
|
676
|
+
main_log = logs_dir / "main.log"
|
|
677
|
+
is_running: bool = main_log.exists() and not is_log_complete(main_log)
|
|
678
|
+
|
|
679
|
+
timeline = build_timeline(logs_dir, known_agents, is_running=is_running)
|
|
680
|
+
if not timeline:
|
|
681
|
+
return 1
|
|
682
|
+
|
|
683
|
+
print_timeline(timeline)
|
|
684
|
+
return 0
|