Skip to content

Commit be817dc

Browse files
committed
feat(state-visualize): add agent-state-visualize workflow
New workflow for visualizing hierarchical task state as ASCII tree. - Status icons (done, failed, in-progress, pending, blocked) - Duration calculations from timestamps - Key data field extraction - Summary statistics (total, done, failed, in_progress, pending)
1 parent 096904a commit be817dc

2 files changed

Lines changed: 268 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,4 @@ src/workflows_mcp/templates/agents/*
224224
!src/workflows_mcp/templates/agents/workflow-creator/
225225
!src/workflows_mcp/templates/agents/pr-review/
226226
!src/workflows_mcp/templates/agents/state-management/
227+
!src/workflows_mcp/templates/agents/state-visualize/
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
name: agent-state-visualize
2+
description: |
3+
Visualize hierarchical task state as ASCII tree.
4+
5+
Renders task tree with status icons, durations, and optional data fields.
6+
Useful for debugging and progress tracking.
7+
8+
Example output:
9+
PR Review [task-fa67c4a3] done (23.4s)
10+
├─ Context Gathering ✓ 2.3s
11+
├─ Initial Assessment ✓ 4.1s (risk: high)
12+
├─ Investigation Loop ● 15.7s
13+
│ ├─ file1.py ✓ 3.2s
14+
│ └─ file2.py ○ pending
15+
└─ Synthesis ○ pending
16+
17+
tags: [agent, state-management, visualization, debugging, tree]
18+
19+
inputs:
20+
state:
21+
type: str
22+
description: |
23+
Path to state file (JSON).
24+
Must be a valid state file created by agent-state-management.
25+
required: true
26+
27+
show_data:
28+
type: bool
29+
description: |
30+
Include key data fields in output.
31+
Shows select fields like 'risk', 'platform', etc. in parentheses.
32+
required: false
33+
default: true
34+
35+
max_depth:
36+
type: num
37+
description: |
38+
Maximum tree depth to show (0 = unlimited).
39+
Useful for large state trees.
40+
required: false
41+
default: 0
42+
43+
task_id:
44+
type: str
45+
description: |
46+
Task ID to use as root for visualization.
47+
Defaults to the root_task_id from state.
48+
required: false
49+
default: ""
50+
51+
blocks:
52+
# ==========================================================================
53+
# Render tree and compute statistics
54+
# ==========================================================================
55+
- id: render
56+
type: Shell
57+
description: Render ASCII tree and compute statistics.
58+
inputs:
59+
command: |
60+
python3 << 'EOF'
61+
import os
62+
import json
63+
from datetime import datetime
64+
65+
# Load state from file
66+
state_path = os.environ['STATE_PATH']
67+
with open(state_path) as f:
68+
state = json.load(f)
69+
show_data = os.environ.get('SHOW_DATA', 'true').lower() == 'true'
70+
max_depth = int(os.environ.get('MAX_DEPTH', '0'))
71+
task_id_input = os.environ.get('TASK_ID', '').strip()
72+
73+
# Status icons
74+
STATUS_ICONS = {
75+
'done': '\u2713', # ✓
76+
'failed': '\u2717', # ✗
77+
'in-progress': '\u25cf', # ●
78+
'pending': '\u25cb', # ○
79+
'blocked': '\u26a0', # ⚠
80+
}
81+
82+
# Data fields to show (in priority order)
83+
SHOW_FIELDS = ['risk_level', 'platform', 'focus', 'files_changed', 'approve']
84+
85+
def parse_iso(dt_str):
86+
"""Parse ISO datetime string."""
87+
if not dt_str:
88+
return None
89+
try:
90+
# Handle various ISO formats
91+
dt_str = dt_str.replace('Z', '+00:00')
92+
if '+' in dt_str:
93+
dt_str = dt_str.rsplit('+', 1)[0]
94+
return datetime.fromisoformat(dt_str)
95+
except (ValueError, TypeError):
96+
return None
97+
98+
def format_duration(seconds):
99+
"""Format duration in human-readable form."""
100+
if seconds < 0.1:
101+
return ""
102+
elif seconds < 60:
103+
return f"{seconds:.1f}s"
104+
elif seconds < 3600:
105+
mins = int(seconds // 60)
106+
secs = seconds % 60
107+
return f"{mins}m{secs:.0f}s"
108+
else:
109+
hours = int(seconds // 3600)
110+
mins = int((seconds % 3600) // 60)
111+
return f"{hours}h{mins}m"
112+
113+
def get_duration(task):
114+
"""Calculate task duration from timestamps."""
115+
created = parse_iso(task.get('created_at'))
116+
updated = parse_iso(task.get('updated_at'))
117+
if created and updated:
118+
return (updated - created).total_seconds()
119+
return 0
120+
121+
def get_data_summary(task, show_data):
122+
"""Extract key data fields for display."""
123+
if not show_data:
124+
return ""
125+
data = task.get('data', {})
126+
if not data:
127+
return ""
128+
parts = []
129+
for field in SHOW_FIELDS:
130+
if field in data:
131+
val = data[field]
132+
if val not in (None, '', 0, False):
133+
# Shorten field name for display
134+
short = field.replace('_level', '').replace('_changed', '')
135+
parts.append(f"{short}: {val}")
136+
return f" ({', '.join(parts)})" if parts else ""
137+
138+
def render_tree(state, task_id, prefix="", is_last=True, depth=0, lines=None):
139+
"""Render task tree recursively."""
140+
if lines is None:
141+
lines = []
142+
143+
tasks = state.get('tasks', {})
144+
if task_id not in tasks:
145+
return lines
146+
147+
task = tasks[task_id]
148+
status = task.get('status', 'pending')
149+
icon = STATUS_ICONS.get(status, '?')
150+
151+
# Duration
152+
duration = get_duration(task)
153+
duration_str = format_duration(duration) if duration else ""
154+
155+
# Data summary
156+
data_str = get_data_summary(task, show_data)
157+
158+
# Build line
159+
if depth == 0:
160+
# Root task - no connector
161+
task_name = task.get('task', task_id)
162+
line = f"{task_name} [{task_id}] {icon} {status}"
163+
if duration_str:
164+
line += f" ({duration_str})"
165+
line += data_str
166+
else:
167+
connector = "\u2514\u2500" if is_last else "\u251c\u2500" # └─ or ├─
168+
task_name = task.get('task', task_id)
169+
line = f"{prefix}{connector} {task_name} {icon}"
170+
if duration_str:
171+
line += f" {duration_str}"
172+
line += data_str
173+
174+
lines.append(line)
175+
176+
# Check depth limit
177+
if max_depth > 0 and depth >= max_depth:
178+
children = task.get('children', [])
179+
if children:
180+
child_prefix = prefix + (" " if is_last else "\u2502 ")
181+
lines.append(f"{child_prefix}... ({len(children)} children)")
182+
return lines
183+
184+
# Recurse for children
185+
children = task.get('children', [])
186+
for i, child_id in enumerate(children):
187+
child_is_last = (i == len(children) - 1)
188+
child_prefix = prefix + (" " if is_last else "\u2502 ") # │
189+
render_tree(state, child_id, child_prefix, child_is_last, depth + 1, lines)
190+
191+
return lines
192+
193+
def compute_statistics(state):
194+
"""Compute summary statistics."""
195+
tasks = state.get('tasks', {})
196+
total = len(tasks)
197+
198+
status_counts = {}
199+
total_duration = 0
200+
max_depth_found = 0
201+
202+
def get_depth(task_id, depth=0):
203+
task = tasks.get(task_id, {})
204+
children = task.get('children', [])
205+
if not children:
206+
return depth
207+
return max(get_depth(c, depth + 1) for c in children)
208+
209+
for task_id, task in tasks.items():
210+
status = task.get('status', 'pending')
211+
status_counts[status] = status_counts.get(status, 0) + 1
212+
total_duration += get_duration(task)
213+
214+
root_id = state.get('root_task_id')
215+
if root_id:
216+
max_depth_found = get_depth(root_id)
217+
218+
return {
219+
'total_tasks': total,
220+
'status_counts': status_counts,
221+
'total_duration': round(total_duration, 2),
222+
'total_duration_formatted': format_duration(total_duration),
223+
'max_depth': max_depth_found,
224+
'done': status_counts.get('done', 0),
225+
'failed': status_counts.get('failed', 0),
226+
'in_progress': status_counts.get('in-progress', 0),
227+
'pending': status_counts.get('pending', 0)
228+
}
229+
230+
# Determine root task
231+
root_id = task_id_input or state.get('root_task_id')
232+
if not root_id:
233+
# Fallback: find task with no parent
234+
for tid, task in state.get('tasks', {}).items():
235+
if not task.get('parent_id'):
236+
root_id = tid
237+
break
238+
239+
# Render tree
240+
tree_lines = render_tree(state, root_id) if root_id else ["No tasks found"]
241+
tree_output = "\n".join(tree_lines)
242+
243+
# Compute statistics
244+
summary = compute_statistics(state)
245+
246+
# Output
247+
print(json.dumps({
248+
'tree': tree_output,
249+
'summary': summary
250+
}))
251+
EOF
252+
env:
253+
STATE_PATH: "{{inputs.state}}"
254+
SHOW_DATA: "{{inputs.show_data | string | lower}}"
255+
MAX_DEPTH: "{{inputs.max_depth | string}}"
256+
TASK_ID: "{{inputs.task_id}}"
257+
258+
outputs:
259+
tree:
260+
description: ASCII tree visualization of task hierarchy
261+
type: str
262+
value: "{{(blocks.render.outputs.stdout | fromjson).tree}}"
263+
264+
summary:
265+
description: Statistics (total_tasks, done, failed, in_progress, pending, total_duration, max_depth)
266+
type: dict
267+
value: "{{(blocks.render.outputs.stdout | fromjson).summary}}"

0 commit comments

Comments
 (0)