|
| 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