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:
@@ -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", "")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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