feat: add command output capture to troubleshooting sessions (#57)
Engineers can now paste command output during action steps. Output is stored in the session decisions JSONB, displayed in session review, included in all 4 export formats with command context, and preserved in session-to-tree conversions. - Collapsible "Paste Output" textarea on action nodes with commands - 10,000 character limit with live character count - Works on both built-in and custom action steps - Preloads output when revisiting a step via Go Back - All exports show commands run alongside captured output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,30 @@ def _get_step_duration_seconds(decision: dict[str, Any]) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_command_output(decision: dict[str, Any]) -> str | None:
|
||||
"""Extract and normalize command_output from a decision dict."""
|
||||
output = decision.get("command_output")
|
||||
if not isinstance(output, str):
|
||||
return None
|
||||
output = output.strip()
|
||||
return output if output else None
|
||||
|
||||
|
||||
def _find_node_commands(tree_snapshot: dict[str, Any], node_id: str) -> list[str]:
|
||||
"""Find the commands list for a node in the tree snapshot."""
|
||||
def _search(node: dict[str, Any]) -> list[str] | None:
|
||||
if node.get("id") == node_id:
|
||||
return node.get("commands") or []
|
||||
for child in node.get("children", []):
|
||||
result = _search(child)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
structure = tree_snapshot.get("tree_structure") or tree_snapshot
|
||||
return _search(structure) or []
|
||||
|
||||
|
||||
def _get_outcome_label(session: Session) -> str | None:
|
||||
"""Map stored outcome enum to human-friendly label."""
|
||||
outcome = getattr(session, "outcome", None)
|
||||
@@ -137,6 +161,14 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
||||
lines.append(f"**Answer:** {answer}")
|
||||
if notes:
|
||||
lines.append(f"**Notes:** {notes}")
|
||||
if command_output := _get_command_output(decision):
|
||||
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
|
||||
if commands:
|
||||
lines.append(f"**Commands Run:** {', '.join(f'`{c}`' for c in commands)}")
|
||||
lines.append("**Output:**")
|
||||
lines.append("```")
|
||||
lines.append(command_output)
|
||||
lines.append("```")
|
||||
if duration_seconds is not None:
|
||||
lines.append(f"**Duration:** {_format_step_duration(duration_seconds)}")
|
||||
if options.include_timestamps and decision.get("timestamp"):
|
||||
@@ -190,6 +222,13 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
|
||||
lines.append(f" Answer: {answer}")
|
||||
if notes:
|
||||
lines.append(f" Notes: {notes}")
|
||||
if command_output := _get_command_output(decision):
|
||||
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
|
||||
if commands:
|
||||
lines.append(f" Commands Run: {', '.join(commands)}")
|
||||
lines.append(" Output:")
|
||||
for output_line in command_output.splitlines():
|
||||
lines.append(f" {output_line}")
|
||||
if duration_seconds is not None:
|
||||
lines.append(f" Duration: {_format_step_duration(duration_seconds)}")
|
||||
|
||||
@@ -253,6 +292,12 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
|
||||
if notes:
|
||||
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
|
||||
if command_output := _get_command_output(decision):
|
||||
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
|
||||
if commands:
|
||||
cmd_html = ", ".join(f"<code>{html.escape(c)}</code>" for c in commands)
|
||||
html_parts.append(f'<p><strong>Commands Run:</strong> {cmd_html}</p>')
|
||||
html_parts.append(f'<pre><code>{html.escape(command_output)}</code></pre>')
|
||||
if duration_seconds is not None:
|
||||
html_parts.append(f'<p class="duration"><strong>Duration:</strong> {_format_step_duration(duration_seconds)}</p>')
|
||||
if options.include_timestamps and decision.get("timestamp"):
|
||||
@@ -304,6 +349,13 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
|
||||
lines.append(line)
|
||||
if notes:
|
||||
lines.append(f" Notes: {notes}")
|
||||
if command_output := _get_command_output(decision):
|
||||
commands = _find_node_commands(session.tree_snapshot, decision.get("node_id", ""))
|
||||
if commands:
|
||||
lines.append(f" Commands: {', '.join(commands)}")
|
||||
lines.append(" Output:")
|
||||
for output_line in command_output.splitlines():
|
||||
lines.append(f" {output_line}")
|
||||
else:
|
||||
lines.append("No steps recorded.")
|
||||
lines.append("")
|
||||
|
||||
Reference in New Issue
Block a user