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:
chihlasm
2026-02-12 00:57:33 -05:00
parent 9dbb8b8406
commit 577a2fbf2a
7 changed files with 413 additions and 3 deletions

View File

@@ -133,6 +133,10 @@ def _create_node_from_original(
new_node["action"] = original_node.get("action", "")
if decision and decision.get("action_performed"):
new_node["action"] = decision["action_performed"]
if decision and decision.get("command_output"):
output = decision["command_output"].strip()
if output:
new_node["action"] += f"\n\nCommand Output:\n{output}"
elif node_type == "solution":
new_node["solution"] = original_node.get("solution", "")

View File

@@ -28,6 +28,7 @@ class DecisionRecord(BaseModel):
answer: Optional[str] = None
action_performed: Optional[str] = None
notes: Optional[str] = None
command_output: Optional[str] = Field(None, max_length=10000)
automation_used: Optional[bool] = False
timestamp: datetime
entered_at: Optional[datetime] = None

View File

@@ -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("")