diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 86fe6702..765b0ede 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -718,7 +718,22 @@ def load_directory_transcripts( ) ] - return dag_ordered + non_dag_entries, tree + # Discover + parse any dynamic-workflow runs under this directory and stash + # them on the tree, keyed by runId, for the renderer to splice in (#174 PR3). + from .workflow import load_workflow_runs, map_workflow_runs_by_tool_use + + tree.workflow_runs = { + run.run_id: run for run in load_workflow_runs(directory_path, silent=silent) + } + # Resolve {tool_use_id: run} once, at full-session scope (BEFORE the renderer + # paginates), so a Workflow tool_use links to its run even when its + # tool_result lands on a different page (#174 PR3, pagination-boundary fix). + all_entries = dag_ordered + non_dag_entries + tree.workflow_links = map_workflow_runs_by_tool_use( + all_entries, list(tree.workflow_runs.values()) + ) + + return all_entries, tree # ============================================================================= @@ -1582,6 +1597,25 @@ def convert_jsonl_to( _integrate_agent_entries(messages) title = f"Claude Transcript - {input_path.stem}" cache_was_updated = False # No cache in single file mode + + # Single-file workflow support (#174 PR3): a lone ``.jsonl`` still + # has its run data in the sibling ``/subagents/workflows/`` dir, so + # discover + link it exactly like directory mode and splice the tree. + # Only build a SessionTree when runs exist β€” otherwise leave + # ``session_tree=None`` so the no-workflow single-file path (the common + # case) is byte-identical to before. + from .workflow import ( + load_session_workflow_runs, + map_workflow_runs_by_tool_use, + ) + + single_file_runs = load_session_workflow_runs(input_path, silent=silent) + if single_file_runs: + session_tree = build_dag_from_entries(messages) + session_tree.workflow_runs = {r.run_id: r for r in single_file_runs} + session_tree.workflow_links = map_workflow_runs_by_tool_use( + messages, single_file_runs + ) else: # Directory mode - Cache-First Approach # `output_root` (#151) decouples the output destination from diff --git a/claude_code_log/dag.py b/claude_code_log/dag.py index d234573c..9603e037 100644 --- a/claude_code_log/dag.py +++ b/claude_code_log/dag.py @@ -8,7 +8,10 @@ import logging from dataclasses import dataclass, field -from typing import Optional +from typing import Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from .workflow import WorkflowRun from .models import ( AiTitleTranscriptEntry, @@ -93,6 +96,18 @@ class SessionTree: sessions: dict[str, SessionDAGLine] roots: list[str] # Root session IDs (no parent session) junction_points: dict[str, JunctionPoint] + # Parsed dynamic-workflow runs keyed by runId (issue #174 PR3), populated + # by load_directory_transcripts. Empty for single-file / non-workflow loads. + workflow_runs: dict[str, "WorkflowRun"] = field( # pyright: ignore[reportUnknownVariableType] + default_factory=dict + ) + # {Workflow tool_use_id: WorkflowRun}, resolved at full-session scope BEFORE + # pagination splits messages into pages (#174 PR3). Lets the per-page linker + # attach a run to its Workflow tool_use even when the tool_use and its + # tool_result land on different pages. Empty for non-workflow loads. + workflow_links: dict[str, "WorkflowRun"] = field( # pyright: ignore[reportUnknownVariableType] + default_factory=dict + ) # ============================================================================= diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 3a42b016..863fb6a9 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -33,6 +33,8 @@ UserMemoryMessage, UserSlashCommandMessage, UserTextMessage, + WorkflowAgentMessage, + WorkflowPhaseMessage, # Tool input types AskUserQuestionInput, AskUserQuestionItem, @@ -170,6 +172,8 @@ format_websearch_output, format_webfetch_input, format_workflow_input, + format_workflow_phase_content, + format_workflow_agent_content, format_webfetch_output, format_monitor_input, format_monitor_output, @@ -583,6 +587,18 @@ def format_UnknownMessage(self, content: UnknownMessage, _: TemplateMessage) -> """Format β†’
JSON dump
.""" return format_unknown_content(content) + def format_WorkflowPhaseMessage( + self, content: WorkflowPhaseMessage, _: TemplateMessage + ) -> str: + """Format β†’ spliced workflow phase card body (detail + agent count).""" + return format_workflow_phase_content(content) + + def format_WorkflowAgentMessage( + self, content: WorkflowAgentMessage, _: TemplateMessage + ) -> str: + """Format β†’ spliced workflow agent card body (meta line + result).""" + return format_workflow_agent_content(content) + # ------------------------------------------------------------------------- # Tool Input Formatters # ------------------------------------------------------------------------- diff --git a/claude_code_log/html/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css index a2ecbfc3..568cebc5 100644 --- a/claude_code_log/html/templates/components/message_styles.css +++ b/claude_code_log/html/templates/components/message_styles.css @@ -1344,3 +1344,45 @@ details summary { .workflow-script { margin-top: 4px; } + +/* Spliced dynamic-workflow run tree (#174 PR3): phase + agent cards. */ +.workflow-phase-meta, +.workflow-agent-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + font-size: 0.85em; + color: var(--text-muted); +} + +.workflow-phase-detail { + color: var(--text-secondary); +} + +.workflow-phase-count, +.workflow-agent-tokens, +.workflow-agent-tools { + font-size: 0.95em; + color: var(--text-muted); +} + +.workflow-agent-model { + font-family: var(--font-mono, monospace); + color: var(--text-secondary); +} + +.workflow-agent-state { + font-style: italic; +} + +.workflow-agent-result, +.workflow-agent-result-preview { + display: block; + margin-top: 6px; +} + +.workflow-agent-result-preview { + color: var(--text-muted); + font-size: 0.9em; +} diff --git a/claude_code_log/html/templates/components/timeline.html b/claude_code_log/html/templates/components/timeline.html index 7af6934a..b68766db 100644 --- a/claude_code_log/html/templates/components/timeline.html +++ b/claude_code_log/html/templates/components/timeline.html @@ -36,7 +36,9 @@ 'bash-input': { id: 'bash-input', content: 'πŸ’» Bash Input', style: 'background-color: #e8eaf6;' }, 'bash-output': { id: 'bash-output', content: 'πŸ“„ Bash Output', style: 'background-color: #efebe9;' }, 'teammate': { id: 'teammate', content: 'πŸ‘₯ Teammate', style: 'background-color: #e8f2fd;' }, - 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' } + 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' }, + 'workflow_phase': { id: 'workflow_phase', content: '🧩 Workflow Phase', style: 'background-color: #fff8e1;' }, + 'workflow_agent': { id: 'workflow_agent', content: 'πŸ€– Workflow Agent', style: 'background-color: #f3e5f5;' } }; // Build timeline data from messages @@ -95,6 +97,15 @@ // they'd land in the `user` group rather than getting // their own πŸ”„ Async result row in the timeline. messageType = 'task-notification'; + } else if (classList.includes('workflow_phase')) { + // Spliced dynamic-workflow phase/agent cards (#174 PR3) + // carry the `tool_use` class for filter visibility, so β€” + // like `teammate`/`task-notification` β€” they need an + // explicit branch before the generic `.find` below, else + // they'd be swept into the πŸ› οΈ Tool Use lane. + messageType = 'workflow_phase'; + } else if (classList.includes('workflow_agent')) { + messageType = 'workflow_agent'; } else { // Look for standard message types messageType = classList.find(cls => diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 3fc8381a..ae570cbf 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -32,7 +32,7 @@ resolve_memory_body_links, ) from ..utils import strip_error_tags -from ..workflow import parse_workflow_meta +from ..workflow import resolve_workflow_header from ..models import ( AskUserQuestionInput, AskUserQuestionItem, @@ -68,6 +68,8 @@ WebSearchOutput, WebFetchInput, WebFetchOutput, + WorkflowAgentMessage, + WorkflowPhaseMessage, WorkflowToolInput, WriteInput, WriteOutput, @@ -1190,7 +1192,9 @@ def format_workflow_input(workflow_input: WorkflowToolInput) -> str: ``meta`` block (name / description / phase pills) above the JavaScript orchestrator source, syntax-highlighted and collapsible when long.""" script = workflow_input.script or "" - name, description, phases = parse_workflow_meta(script) + name, description, phases = resolve_workflow_header( + workflow_input.workflow_run, script + ) header_parts: list[str] = [] if name: @@ -1223,6 +1227,83 @@ def format_workflow_input(workflow_input: WorkflowToolInput) -> str: return f"{header}{body}" +# -- Workflow run tree: phase + agent cards (issue #174 PR3) ------------------- + + +def format_workflow_phase_content(content: WorkflowPhaseMessage) -> str: + """Format a spliced workflow *phase* card body: the phase ``detail`` plus + its agent count. The phase title is the card heading (``title_content``).""" + parts: list[str] = [] + if content.detail: + parts.append( + f"{escape_html(content.detail)}" + ) + if content.agent_count: + unit = "agent" if content.agent_count == 1 else "agents" + parts.append( + f"{content.agent_count} {unit}" + ) + if not parts: + return "" + return f"
{''.join(parts)}
" + + +def format_workflow_agent_content(content: WorkflowAgentMessage) -> str: + """Format a spliced workflow *agent* card body: a metadata chrome line + (model / state / tokens / tool calls) above the agent's result β€” a + ``StructuredOutput`` dict pretty-printed + highlighted as JSON, a plain + string rendered as collapsible Markdown. The agent's side-channel + transcript renders separately as this node's ``.children``.""" + meta_bits: list[str] = [] + if content.model: + meta_bits.append( + f"{escape_html(content.model)}" + ) + if content.state: + meta_bits.append( + f"{escape_html(content.state)}" + ) + if content.tokens is not None: + meta_bits.append( + f"{content.tokens} tokens" + ) + if content.tool_calls is not None: + unit = "call" if content.tool_calls == 1 else "calls" + meta_bits.append( + f"{content.tool_calls} tool {unit}" + ) + parts: list[str] = [] + if meta_bits: + parts.append(f"
{''.join(meta_bits)}
") + + result = content.result + if isinstance(result, (dict, list)): + # Pretty-print + JSON-highlight directly. NOT via render_async_result_body + # β€” its JSON heuristic only fires on `{"`-shaped text, so a list-shaped + # StructuredOutput result (``[...]``) would fall through to the markdown + # path and lose JSON highlighting (and diverge from the Markdown renderer, + # which fences both dict and list as JSON). A real dict/list always + # serializes to valid JSON, so highlight it unconditionally (CR #210). + pretty = json.dumps(result, indent=2, ensure_ascii=False) + parts.append( + render_file_content_collapsible( + pretty, + "result.json", + "workflow-agent-result", + line_threshold=10, + preview_line_count=6, + ) + ) + elif isinstance(result, str) and result.strip(): + parts.append(render_markdown_collapsible(result, "workflow-agent-result")) + elif content.result_preview: + parts.append( + f"" + f"{escape_html(content.result_preview)}" + ) + return "".join(parts) + + # -- Public Exports ----------------------------------------------------------- __all__ = [ @@ -1241,6 +1322,8 @@ def format_workflow_input(workflow_input: WorkflowToolInput) -> str: "format_websearch_input", "format_webfetch_input", "format_workflow_input", + "format_workflow_phase_content", + "format_workflow_agent_content", "format_monitor_input", "format_schedulewakeup_input", "format_croncreate_input", diff --git a/claude_code_log/html/utils.py b/claude_code_log/html/utils.py index 295cf015..52dd2349 100644 --- a/claude_code_log/html/utils.py +++ b/claude_code_log/html/utils.py @@ -47,6 +47,8 @@ UserSlashCommandMessage, UserSteeringMessage, UserTextMessage, + WorkflowAgentMessage, + WorkflowPhaseMessage, ) from ..renderer_timings import timing_stat @@ -190,6 +192,13 @@ def _rewrite(m: "re.Match[str]") -> str: BashInputMessage: ["bash-input"], BashOutputMessage: ["bash-output"], UnknownMessage: ["unknown"], + # Dynamic-workflow synthetic nodes (#174 PR3): phase/agent cards spliced + # under a Workflow tool_use. The `tool_use` class keeps them visible under + # the runtime "Tool Use" filter toggle (the splice lives inside a tool_use + # subtree); the `workflow_phase`/`workflow_agent` modifiers drive styling + # and the timeline's dedicated detection branch. + WorkflowPhaseMessage: ["tool_use", "workflow_phase"], + WorkflowAgentMessage: ["tool_use", "workflow_agent"], } @@ -320,6 +329,10 @@ def get_message_emoji(msg: "TemplateMessage") -> str: return "πŸ’­" elif msg_type == "image": return "πŸ–ΌοΈ" + elif msg_type == "workflow_phase": + return "🧩" + elif msg_type == "workflow_agent": + return "πŸ€–" return "" diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index 0537a6c9..5187480c 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -69,6 +69,8 @@ SkillInput, WebSearchInput, WebFetchInput, + WorkflowAgentMessage, + WorkflowPhaseMessage, WorkflowToolInput, MonitorInput, MonitorOutput, @@ -1045,10 +1047,10 @@ def format_WorkflowToolInput( would emit nothing for the script (a regression from the pre-PR ToolUseContent ``**script:**`` fallback). """ - from ..workflow import parse_workflow_meta + from ..workflow import resolve_workflow_header script = input.script or "" - name, description, phases = parse_workflow_meta(script) + name, description, phases = resolve_workflow_header(input.workflow_run, script) parts: list[str] = [] header_bits: list[str] = [] @@ -1071,6 +1073,52 @@ def format_WorkflowToolInput( parts.append(self._code_fence(script, "js")) return "\n\n".join(parts) + def format_WorkflowPhaseMessage( + self, content: WorkflowPhaseMessage, _: TemplateMessage + ) -> str: + """Format β†’ a spliced workflow phase card body: detail + agent count + (issue #174 PR3). The phase title is the heading (``title_content``).""" + bits: list[str] = [] + if content.detail: + bits.append(_protect_html_tags(content.detail)) + if content.agent_count: + unit = "agent" if content.agent_count == 1 else "agents" + bits.append(f"_{content.agent_count} {unit}_") + return " β€” ".join(bits) + + def format_WorkflowAgentMessage( + self, content: WorkflowAgentMessage, _: TemplateMessage + ) -> str: + """Format β†’ a spliced workflow agent card body: a meta line (model / + state / tokens / tool calls) above the result β€” a StructuredOutput dict + fenced as JSON, a plain string emitted as Markdown (issue #174 PR3).""" + parts: list[str] = [] + meta_bits: list[str] = [] + if content.model: + meta_bits.append(f"model: `{_protect_html_tags(content.model)}`") + if content.state: + meta_bits.append(f"state: {_protect_html_tags(content.state)}") + if content.tokens is not None: + meta_bits.append(f"{content.tokens} tokens") + if content.tool_calls is not None: + unit = "call" if content.tool_calls == 1 else "calls" + meta_bits.append(f"{content.tool_calls} tool {unit}") + if meta_bits: + parts.append("_" + " Β· ".join(meta_bits) + "_") + + result = content.result + if isinstance(result, (dict, list)): + parts.append( + self._code_fence( + json.dumps(result, indent=2, ensure_ascii=False), "json" + ) + ) + elif isinstance(result, str) and result.strip(): + parts.append(result) + elif content.result_preview: + parts.append(_protect_html_tags(content.result_preview)) + return "\n\n".join(parts) + def format_MonitorInput(self, input: MonitorInput, _: TemplateMessage) -> str: """Format β†’ bullet list of fields with the command in a fenced block. diff --git a/claude_code_log/models.py b/claude_code_log/models.py index cc5b507a..0cc8a4d6 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -1140,6 +1140,49 @@ def message_type(self) -> str: return "tool_use" +@dataclass +class WorkflowPhaseMessage(MessageContent): + """Synthetic node (#174 PR3): a dynamic-workflow *phase* header card. + + Spliced under a ``Workflow`` tool_use node (Strategy B β€” built post + ``_build_message_tree``, not parsed from the raw transcript). Its + ``.children`` are the phase's :class:`WorkflowAgentMessage` nodes. Built + from a parsed ``WorkflowPhase``; uses ``MessageMeta.empty()`` for meta. + """ + + title: str = "" + detail: str = "" + agent_count: int = 0 + + @property + def message_type(self) -> str: + return "workflow_phase" + + +@dataclass +class WorkflowAgentMessage(MessageContent): + """Synthetic node (#174 PR3): one dynamic-workflow *sub-agent* card. + + Spliced under its :class:`WorkflowPhaseMessage` (or directly under the + Workflow tool_use when there's no snapshot/phase grouping). Its + ``.children`` are the agent's side-channel transcript messages. ``result`` + is the agent's output β€” a dict for ``StructuredOutput`` agents, a string + for plain-text agents, or ``None`` while in flight. + """ + + label: str = "" + model: str = "" + state: str = "" + tokens: Optional[int] = None + tool_calls: Optional[int] = None + result: Any = None + result_preview: str = "" + + @property + def message_type(self) -> str: + return "workflow_agent" + + # ============================================================================= # Tool Input Models # ============================================================================= @@ -1556,6 +1599,13 @@ class WorkflowToolInput(BaseModel): script: str = "" + # Renderer-set (issue #174 PR3): the parsed WorkflowRun linked to this + # tool_use by taskId, when its .json snapshot was found on disk. + # Lets the formatter prefer the authoritative snapshot meta (name / + # description / phases) over the best-effort JS-`meta` regex. Typed Any to + # avoid importing the dataclass into this Pydantic model. + workflow_run: Optional[Any] = None + model_config = {"extra": "allow"} diff --git a/claude_code_log/renderer.py b/claude_code_log/renderer.py index 2bea38e6..6bf3084a 100644 --- a/claude_code_log/renderer.py +++ b/claude_code_log/renderer.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: from .cache import CacheManager from .dag import SessionTree + from .workflow import WorkflowRun from .models import ( DetailLevel, @@ -52,6 +53,9 @@ ThinkingMessage, ToolResultMessage, ToolUseMessage, + WorkflowAgentMessage, + WorkflowPhaseMessage, + WorkflowToolInput, UnknownMessage, UserMemoryMessage, UserSlashCommandMessage, @@ -452,6 +456,8 @@ def _format_type_counts(type_counts: dict[str, int]) -> str: "system-error": ("error", "errors"), "system-info": ("info", "infos"), "sidechain": ("task", "tasks"), + "workflow_phase": ("phase", "phases"), + "workflow_agent": ("agent", "agents"), } # Handle special case: tool_use and tool_result together = "tool pairs" @@ -851,6 +857,16 @@ def generate_template_messages( with log_timing("Link async notifications", t_start): _link_async_notifications(ctx, detail) + # Link parsed dynamic-workflow runs to their Workflow tool_use by taskId + # (#174 PR3) so the formatter can render snapshot-first meta (and step 3 + # can splice the phase/agent tree). + with log_timing("Link workflow runs", t_start): + _link_workflow_runs( + ctx, + session_tree.workflow_runs if session_tree is not None else {}, + session_tree.workflow_links if session_tree is not None else None, + ) + # Independent pass: link tool-use-id-bearing notifications (e.g. # built-in Monitor task-end) back to their originating tool_use. # Distinct from the agent-spawn flow above β€” there's no fold or @@ -875,6 +891,13 @@ def generate_template_messages( with log_timing("Link task_id consumers", t_start): _link_task_id_consumers(ctx) + # MUST be last (#174 PR3): splice each linked WorkflowRun's phase/agent + # sub-tree under its Workflow tool_use node. Registers synthetic + grafted + # nodes (appending to ctx.messages via the monotonic ctx.register + # allocator), so it has to follow every ctx.messages-iterating pass above. + with log_timing("Splice workflow runs", t_start): + _splice_workflow_runs(ctx) + return root_messages, session_nav, ctx @@ -2693,6 +2716,321 @@ def _link_tool_use_notifications(ctx: RenderingContext) -> None: content.spawning_task_message_index = target_idx +_WF_TASK_ID_RE = re.compile(r"Task ID:\s*(\S+)") + + +def _result_text_for_taskid(output: Any) -> str: + """Best-effort plain text of a tool_result output for taskId extraction.""" + for attr in ("content", "result"): + value = getattr(output, attr, None) + if isinstance(value, str): + return value + return "" + + +def _link_workflow_runs( + ctx: RenderingContext, + workflow_runs: "dict[str, WorkflowRun]", + links: "Optional[dict[str, WorkflowRun]]" = None, +) -> None: + """Link each parsed WorkflowRun to its Workflow tool_use by taskId (#174 PR3). + + Two paths: + + 1. **Preferred** β€” a precomputed ``{tool_use_id: WorkflowRun}`` map built at + full-session scope (``SessionTree.workflow_links``, via + :func:`workflow.map_workflow_runs_by_tool_use`). Resolved BEFORE + pagination, it links a Workflow tool_use to its run even when the + tool_use and its tool_result land on different pages β€” and it's how + single-file rendering links too. + 2. **Fallback** β€” when no map is supplied (e.g. a direct + ``generate_template_messages`` call): scan this render's tool_results for + ``Task ID: `` (the runId lives only in the dropped + ``toolUseResult``) and match to ``WorkflowRun.task_id``. Works when the + tool_use and its tool_result share this ``ctx.messages`` (no pagination). + + Either way the run is stashed on the tool_use's ``WorkflowToolInput``, + enabling the snapshot-first meta header and the phase/agent tree splice. + """ + if links: + for tm in _visible(ctx.messages): + content = tm.content + if ( + isinstance(content, ToolUseMessage) + and content.tool_name == "Workflow" + and isinstance(content.input, WorkflowToolInput) + and content.tool_use_id + ): + run = links.get(content.tool_use_id) + if run is not None: + content.input.workflow_run = run + return + if not workflow_runs: + return + runs_by_task = {r.task_id: r for r in workflow_runs.values() if r.task_id} + if not runs_by_task: + return + inputs_by_tool_use_id: dict[str, WorkflowToolInput] = {} + for tm in _visible(ctx.messages): + content = tm.content + if ( + isinstance(content, ToolUseMessage) + and content.tool_name == "Workflow" + and isinstance(content.input, WorkflowToolInput) + and content.tool_use_id + ): + inputs_by_tool_use_id[content.tool_use_id] = content.input + if not inputs_by_tool_use_id: + return + for tm in _visible(ctx.messages): + content = tm.content + if not ( + isinstance(content, ToolResultMessage) and content.tool_name == "Workflow" + ): + continue + wf_input = inputs_by_tool_use_id.get(content.tool_use_id) + if wf_input is None: + continue + match = _WF_TASK_ID_RE.search(_result_text_for_taskid(content.output)) + if match: + run = runs_by_task.get(match.group(1)) + if run is not None: + wf_input.workflow_run = run + + +def _splice_workflow_runs(ctx: RenderingContext) -> None: + """Splice each linked ``WorkflowRun`` as a sub-tree under its Workflow + tool_use node β€” phases β†’ agents β†’ each agent's side-channel transcript + (#174 PR3, step 3). + + Strategy B: a self-contained sub-tree built *after* ``_build_message_tree`` + and attached via ``.children`` (the render walks recurse children, so no + ancestry rebuild is needed). Runs LAST in ``generate_template_messages``: + it appends synthetic + grafted nodes through ``ctx.register`` β€” an + inherently session-wide monotonic index allocator (``len(ctx.messages)``) + that keeps indices collision-free across several / concurrent workflows in + one session β€” so it must follow every pass that iterates ``ctx.messages``. + """ + hosts: list[tuple[TemplateMessage, Any]] = [] + for tm in _visible(ctx.messages): + content = tm.content + if ( + isinstance(content, ToolUseMessage) + and content.tool_name == "Workflow" + and isinstance(content.input, WorkflowToolInput) + and content.input.workflow_run is not None + ): + hosts.append((tm, content.input.workflow_run)) + # Register AFTER collecting hosts β€” registering mutates ctx.messages. + for host, run in hosts: + _splice_one_workflow_run(ctx, host, run) + + +def _splice_one_workflow_run( + ctx: RenderingContext, host: TemplateMessage, run: Any +) -> None: + """Build + attach one run's phase/agent sub-tree under ``host`` (the + Workflow tool_use node).""" + if host.message_index is None: + return + # Phase grouping when the snapshot supplied phases; else a flat agent list + # directly under the tool_use (running / no-snapshot view). + if getattr(run, "has_snapshot", False) and run.phases: + groups: list[tuple[Any, list[Any]]] = [ + (phase, list(phase.agents)) for phase in run.phases + ] + else: + groups = [(None, list(run.agents))] + + spliced_top: list[TemplateMessage] = [] + for phase, agents in groups: + if phase is not None: + phase_tm = _new_synthetic_node( + ctx, + WorkflowPhaseMessage( + meta=MessageMeta.empty(), + title=phase.title, + detail=phase.detail, + agent_count=len(agents), + ), + parent=host, + ) + spliced_top.append(phase_tm) + agent_parent: Optional[TemplateMessage] = phase_tm + else: + agent_parent = None + + agent_nodes: list[TemplateMessage] = [] + for agent in agents: + base = agent_parent if agent_parent is not None else host + agent_tm = _new_synthetic_node( + ctx, + WorkflowAgentMessage( + meta=MessageMeta.empty(), + label=agent.label or agent.agent_id, + model=agent.model, + state=agent.state, + tokens=agent.tokens, + tool_calls=agent.tool_calls, + result=agent.result, + result_preview=agent.result_preview, + ), + parent=base, + ) + _graft_agent_sidechannel(ctx, agent_tm, agent.entries) + agent_nodes.append(agent_tm) + + if agent_parent is not None: + agent_parent.children = agent_nodes + else: + spliced_top.extend(agent_nodes) + + host.children = list(host.children) + spliced_top + _recount_spliced_children(ctx, host, spliced_top) + + +def _new_synthetic_node( + ctx: RenderingContext, content: "MessageContent", *, parent: TemplateMessage +) -> TemplateMessage: + """Register a synthetic workflow node (phase/agent) and set its ancestry + from ``parent``. Index allocation is via ``ctx.register`` (monotonic).""" + tm = TemplateMessage(content) + ctx.register(tm) + if parent.message_index is not None: + tm.ancestry = list(parent.ancestry) + [parent.message_index] + return tm + + +def _graft_agent_sidechannel( + ctx: RenderingContext, + agent_tm: TemplateMessage, + entries: "list[TranscriptEntry]", +) -> None: + """Render an agent's side-channel transcript and graft it under ``agent_tm``. + + Re-renders ``entries`` through the normal pipeline (its own + ``RenderingContext``), then re-registers every produced node into the MAIN + ctx so each ``message_index`` is unique + monotonic, and remaps pairing + references (``pair_first``/``pair_middle``/``pair_last``) into the new index + space so markdown pairing (which resolves partners via the main ctx) still + works. Jump-to-call backlinks computed inside the sub-context are not + remapped β€” a best-effort limitation; workflow agent transcripts are + typically simple read-heavy chains. + """ + if not entries: + return + # The side-channel is rendered at the default FULL detail regardless of the + # main render's detail level (the splice only fires at FULL/HIGH anyway β€” + # the Workflow tool_use host is dropped at LOW and below). So an agent's + # transcript may carry FULL-only content (e.g. system/hook entries) even at + # ``--detail high`` (monk PR3 review N2). Acceptable: the side-channel is an + # opt-in deep-dive under a fold. + sub_roots, _sub_nav, _sub_ctx = generate_template_messages(entries) + old_to_new: dict[int, int] = {} + + def _reindex(node: TemplateMessage, parent: TemplateMessage) -> None: + old = node.message_index + ctx.register(node) + if old is not None and node.message_index is not None: + old_to_new[old] = node.message_index + if parent.message_index is not None: + node.ancestry = list(parent.ancestry) + [parent.message_index] + for child in node.children: + _reindex(child, node) + + grafted: list[TemplateMessage] = [] + for root in sub_roots: + if root.is_session_header: + # Defensive: agent transcripts don't emit a session header, but if + # one appears, graft its children rather than the header chrome. + for child in list(root.children): + _reindex(child, agent_tm) + grafted.append(child) + else: + _reindex(root, agent_tm) + grafted.append(root) + + def _remap_pairs(node: TemplateMessage) -> None: + for attr in ("pair_first", "pair_middle", "pair_last"): + old = getattr(node, attr) + if old is not None and old in old_to_new: + setattr(node, attr, old_to_new[old]) + for child in node.children: + _remap_pairs(child) + + for node in grafted: + _remap_pairs(node) + agent_tm.children = grafted + + +def _recount_spliced_children( + ctx: RenderingContext, + host: TemplateMessage, + new_children: list[TemplateMessage], +) -> None: + """Set descendant counts over each newly-spliced subtree, then add their + contribution to ``host`` and propagate it up ``host``'s existing ancestors. + + Counts are *incremented* on the host (not reset), so this is correct even + if the host already had tree children. Within the workflow subtree every + child is counted (the pairing-skip nuance of + :func:`_mark_messages_with_children` is dropped β€” these are fold-control + label hints inside the run, where the simpler convention is fine).""" + + def _counts(node: TemplateMessage) -> None: + node.immediate_children_count = 0 + node.total_descendants_count = 0 + node.immediate_children_by_type = {} + node.total_descendants_by_type = {} + for child in node.children: + _counts(child) + child_type = child.type + node.immediate_children_count += 1 + node.immediate_children_by_type[child_type] = ( + node.immediate_children_by_type.get(child_type, 0) + 1 + ) + node.total_descendants_count += 1 + child.total_descendants_count + node.total_descendants_by_type[child_type] = ( + node.total_descendants_by_type.get(child_type, 0) + 1 + ) + for sub_type, sub_count in child.total_descendants_by_type.items(): + node.total_descendants_by_type[sub_type] = ( + node.total_descendants_by_type.get(sub_type, 0) + sub_count + ) + + added_total = 0 + added_by_type: dict[str, int] = {} + for child in new_children: + _counts(child) + child_type = child.type + host.immediate_children_count += 1 + host.immediate_children_by_type[child_type] = ( + host.immediate_children_by_type.get(child_type, 0) + 1 + ) + contribution = 1 + child.total_descendants_count + added_total += contribution + added_by_type[child_type] = added_by_type.get(child_type, 0) + 1 + for sub_type, sub_count in child.total_descendants_by_type.items(): + added_by_type[sub_type] = added_by_type.get(sub_type, 0) + sub_count + + host.total_descendants_count += added_total + for sub_type, sub_count in added_by_type.items(): + host.total_descendants_by_type[sub_type] = ( + host.total_descendants_by_type.get(sub_type, 0) + sub_count + ) + # Propagate the added descendants up the host's existing ancestors so their + # fold-control labels stay accurate. + for ancestor_index in host.ancestry: + ancestor = ctx.get(ancestor_index) + if ancestor is None: + continue + ancestor.total_descendants_count += added_total + for sub_type, sub_count in added_by_type.items(): + ancestor.total_descendants_by_type[sub_type] = ( + ancestor.total_descendants_by_type.get(sub_type, 0) + sub_count + ) + + def _link_async_notifications( ctx: RenderingContext, detail: DetailLevel = DetailLevel.FULL ) -> None: @@ -4459,6 +4797,18 @@ def title_ThinkingMessage( ) -> str: return "Thinking" + def title_WorkflowPhaseMessage( + self, content: WorkflowPhaseMessage, _: TemplateMessage + ) -> str: + # Format-neutral header label for a spliced workflow phase card + # (#174 PR3). The agent count + detail render in the body. + return f"Phase: {content.title}" if content.title else "Phase" + + def title_WorkflowAgentMessage( + self, content: WorkflowAgentMessage, _: TemplateMessage + ) -> str: + return content.label or "Agent" + def title_UnknownMessage(self, _content: UnknownMessage, _: TemplateMessage) -> str: return "Unknown Content" diff --git a/claude_code_log/workflow.py b/claude_code_log/workflow.py index df7ca882..8b7de555 100644 --- a/claude_code_log/workflow.py +++ b/claude_code_log/workflow.py @@ -26,11 +26,14 @@ from __future__ import annotations import json +import logging import re from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, cast +logger = logging.getLogger(__name__) + _WF_META_RE = re.compile( r"export\s+(?:const|let|var)\s+meta\s*=\s*\{(.*?)\n\}", re.DOTALL ) @@ -72,6 +75,45 @@ def parse_workflow_meta(script: str) -> tuple[str, str, list[str]]: ) +def resolve_workflow_header( + run: "Optional[WorkflowRun]", script: str +) -> tuple[str, str, list[str]]: + """Resolve ``(name, description, phase_titles)`` for the Workflow header, + **snapshot-first** (issue #174 PR3 / cboos's refinement). + + Prefers the authoritative ``.json`` (``run.workflow_name`` and + ``run.phases`` titles) when a snapshot is present, effectively *back-filling* + the header from the JSON. Falls back to the best-effort JS-``meta`` regex + (:func:`parse_workflow_meta`) for a running workflow with no snapshot. + ``description`` always comes from the JS meta β€” the snapshot carries no + description field. + + When a snapshot IS present but the JS-``meta`` parse missed a field the + snapshot supplies, emit a warning so JS-format drift is noticeable (we can + then adapt the regex). + """ + name_js, description, phases_js = parse_workflow_meta(script) + + if run is not None and getattr(run, "has_snapshot", False): + if run.workflow_name and not name_js: + logger.warning( + "Workflow meta: JS `name` not parsed but snapshot has " + "workflowName=%r β€” the script's `meta` format may have drifted.", + run.workflow_name, + ) + if run.phases and not phases_js: + logger.warning( + "Workflow meta: JS `phases` not parsed but snapshot has %d " + "phase(s) β€” the script's `meta` format may have drifted.", + len(run.phases), + ) + name = run.workflow_name or name_js + phase_titles = [p.title for p in run.phases] or phases_js + return name, description, phase_titles + + return name_js, description, phases_js + + if TYPE_CHECKING: from claude_code_log.models import TranscriptEntry @@ -386,6 +428,19 @@ def discover_workflow_runs(session_dir: Path) -> list[tuple[Path, Optional[Path] return runs +def _runs_in_session_dir( + session_dir: Path, *, silent: bool = True +) -> list[WorkflowRun]: + """Parse every workflow run under one trunk session dir's + ``subagents/workflows/``. Shared by the directory and single-file loaders.""" + runs: list[WorkflowRun] = [] + for run_dir, snapshot in discover_workflow_runs(session_dir): + parsed = parse_workflow_run(run_dir, snapshot, silent=silent) + if parsed is not None: + runs.append(parsed) + return runs + + def load_workflow_runs( directory_path: Path, *, silent: bool = True ) -> list[WorkflowRun]: @@ -397,8 +452,87 @@ def load_workflow_runs( """ runs: list[WorkflowRun] = [] for session_dir in sorted(p for p in directory_path.iterdir() if p.is_dir()): - for run_dir, snapshot in discover_workflow_runs(session_dir): - parsed = parse_workflow_run(run_dir, snapshot, silent=silent) - if parsed is not None: - runs.append(parsed) + runs.extend(_runs_in_session_dir(session_dir, silent=silent)) return runs + + +def load_session_workflow_runs( + transcript_path: Path, *, silent: bool = True +) -> list[WorkflowRun]: + """Discover + parse workflow runs for a SINGLE session transcript file + (issue #174 PR3 β€” single-file rendering). + + The runs live in the sibling ``/subagents/workflows//`` dir + (snapshot ``/workflows/.json``), where ```` is the + transcript filename without its ``.jsonl`` suffix. Mirrors + :func:`load_workflow_runs` scoped to one session, so + ``claude-code-log /.jsonl`` renders the workflow tree just + like a directory load. + """ + return _runs_in_session_dir(transcript_path.with_suffix(""), silent=silent) + + +def _tool_result_text(content: Any) -> str: + """Best-effort plain text from a raw ``ToolResultContent.content`` (a + string, or a list of ``{type, text, ...}`` dicts) for ``Task ID:`` lookup.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in cast("list[Any]", content): + if isinstance(item, dict): + text = cast("dict[str, Any]", item).get("text") + if isinstance(text, str): + parts.append(text) + return "\n".join(parts) + return "" + + +def map_workflow_runs_by_tool_use( + entries: "list[TranscriptEntry]", runs: "list[WorkflowRun]" +) -> dict[str, WorkflowRun]: + """Resolve ``{Workflow tool_use_id: WorkflowRun}`` at full-session scope. + + Matches each ``Workflow`` tool_use to its run via the paired tool_result's + ``Task ID: `` (the ``runId`` lives only in the dropped + ``toolUseResult``) == ``run.task_id``. Built over the WHOLE entry list + *before* pagination splits it into pages, so a tool_use and its tool_result + on different pages still link (the per-page linker alone would miss it). + Also the linkage used for single-file rendering. + """ + from .models import ToolResultContent, ToolUseContent + + runs_by_task = {r.task_id: r for r in runs if r.task_id} + if not runs_by_task: + return {} + + workflow_tool_use_ids: set[str] = set() + for entry in entries: + content = getattr(getattr(entry, "message", None), "content", None) + if not isinstance(content, list): + continue + for item in cast("list[Any]", content): + if isinstance(item, ToolUseContent) and item.name == "Workflow": + workflow_tool_use_ids.add(item.id) + if not workflow_tool_use_ids: + return {} + + links: dict[str, WorkflowRun] = {} + for entry in entries: + content = getattr(getattr(entry, "message", None), "content", None) + if not isinstance(content, list): + continue + for item in cast("list[Any]", content): + if ( + isinstance(item, ToolResultContent) + and item.tool_use_id in workflow_tool_use_ids + ): + match = _WF_TASK_ID_RE_RUNTIME.search(_tool_result_text(item.content)) + if match: + run = runs_by_task.get(match.group(1)) + if run is not None: + links[item.tool_use_id] = run + return links + + +_WF_TASK_ID_RE_RUNTIME = re.compile(r"Task ID:\s*(\S+)") diff --git a/scripts/gen_workflow_fixture.py b/scripts/gen_workflow_fixture.py index a157e0e9..4d72c603 100644 --- a/scripts/gen_workflow_fixture.py +++ b/scripts/gen_workflow_fixture.py @@ -177,7 +177,13 @@ def _trunk() -> list[dict]: { "type": "tool_result", "tool_use_id": "toolu_wf01", - "content": '{"status":"async_launched","runId":"wf_demo01"}', + # Real Workflow tool_results put the human stub + "Task ID: " + # in the content; the runId lives only in toolUseResult. The + # renderer links a run to its tool_use by this taskId. + "content": ( + f"Workflow launched in background. Task ID: {TASK_ID}\n" + "Summary: Review the diff across dimensions." + ), } ], tool_use_result={ diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr index 97c8252a..78623524 100644 --- a/test/__snapshots__/test_snapshot_html.ambr +++ b/test/__snapshots__/test_snapshot_html.ambr @@ -1698,6 +1698,48 @@ .workflow-script { margin-top: 4px; } + + /* Spliced dynamic-workflow run tree (#174 PR3): phase + agent cards. */ + .workflow-phase-meta, + .workflow-agent-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + font-size: 0.85em; + color: var(--text-muted); + } + + .workflow-phase-detail { + color: var(--text-secondary); + } + + .workflow-phase-count, + .workflow-agent-tokens, + .workflow-agent-tools { + font-size: 0.95em; + color: var(--text-muted); + } + + .workflow-agent-model { + font-family: var(--font-mono, monospace); + color: var(--text-secondary); + } + + .workflow-agent-state { + font-style: italic; + } + + .workflow-agent-result, + .workflow-agent-result-preview { + display: block; + margin-top: 6px; + } + + .workflow-agent-result-preview { + color: var(--text-muted); + font-size: 0.9em; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -3604,7 +3646,9 @@ 'bash-input': { id: 'bash-input', content: 'πŸ’» Bash Input', style: 'background-color: #e8eaf6;' }, 'bash-output': { id: 'bash-output', content: 'πŸ“„ Bash Output', style: 'background-color: #efebe9;' }, 'teammate': { id: 'teammate', content: 'πŸ‘₯ Teammate', style: 'background-color: #e8f2fd;' }, - 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' } + 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' }, + 'workflow_phase': { id: 'workflow_phase', content: '🧩 Workflow Phase', style: 'background-color: #fff8e1;' }, + 'workflow_agent': { id: 'workflow_agent', content: 'πŸ€– Workflow Agent', style: 'background-color: #f3e5f5;' } }; // Build timeline data from messages @@ -3663,6 +3707,15 @@ // they'd land in the `user` group rather than getting // their own πŸ”„ Async result row in the timeline. messageType = 'task-notification'; + } else if (classList.includes('workflow_phase')) { + // Spliced dynamic-workflow phase/agent cards (#174 PR3) + // carry the `tool_use` class for filter visibility, so β€” + // like `teammate`/`task-notification` β€” they need an + // explicit branch before the generic `.find` below, else + // they'd be swept into the πŸ› οΈ Tool Use lane. + messageType = 'workflow_phase'; + } else if (classList.includes('workflow_agent')) { + messageType = 'workflow_agent'; } else { // Look for standard message types messageType = classList.find(cls => @@ -7584,6 +7637,48 @@ .workflow-script { margin-top: 4px; } + + /* Spliced dynamic-workflow run tree (#174 PR3): phase + agent cards. */ + .workflow-phase-meta, + .workflow-agent-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + font-size: 0.85em; + color: var(--text-muted); + } + + .workflow-phase-detail { + color: var(--text-secondary); + } + + .workflow-phase-count, + .workflow-agent-tokens, + .workflow-agent-tools { + font-size: 0.95em; + color: var(--text-muted); + } + + .workflow-agent-model { + font-family: var(--font-mono, monospace); + color: var(--text-secondary); + } + + .workflow-agent-state { + font-style: italic; + } + + .workflow-agent-result, + .workflow-agent-result-preview { + display: block; + margin-top: 6px; + } + + .workflow-agent-result-preview { + color: var(--text-muted); + font-size: 0.9em; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -9490,7 +9585,9 @@ 'bash-input': { id: 'bash-input', content: 'πŸ’» Bash Input', style: 'background-color: #e8eaf6;' }, 'bash-output': { id: 'bash-output', content: 'πŸ“„ Bash Output', style: 'background-color: #efebe9;' }, 'teammate': { id: 'teammate', content: 'πŸ‘₯ Teammate', style: 'background-color: #e8f2fd;' }, - 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' } + 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' }, + 'workflow_phase': { id: 'workflow_phase', content: '🧩 Workflow Phase', style: 'background-color: #fff8e1;' }, + 'workflow_agent': { id: 'workflow_agent', content: 'πŸ€– Workflow Agent', style: 'background-color: #f3e5f5;' } }; // Build timeline data from messages @@ -9549,6 +9646,15 @@ // they'd land in the `user` group rather than getting // their own πŸ”„ Async result row in the timeline. messageType = 'task-notification'; + } else if (classList.includes('workflow_phase')) { + // Spliced dynamic-workflow phase/agent cards (#174 PR3) + // carry the `tool_use` class for filter visibility, so β€” + // like `teammate`/`task-notification` β€” they need an + // explicit branch before the generic `.find` below, else + // they'd be swept into the πŸ› οΈ Tool Use lane. + messageType = 'workflow_phase'; + } else if (classList.includes('workflow_agent')) { + messageType = 'workflow_agent'; } else { // Look for standard message types messageType = classList.find(cls => @@ -15552,6 +15658,48 @@ .workflow-script { margin-top: 4px; } + + /* Spliced dynamic-workflow run tree (#174 PR3): phase + agent cards. */ + .workflow-phase-meta, + .workflow-agent-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + font-size: 0.85em; + color: var(--text-muted); + } + + .workflow-phase-detail { + color: var(--text-secondary); + } + + .workflow-phase-count, + .workflow-agent-tokens, + .workflow-agent-tools { + font-size: 0.95em; + color: var(--text-muted); + } + + .workflow-agent-model { + font-family: var(--font-mono, monospace); + color: var(--text-secondary); + } + + .workflow-agent-state { + font-style: italic; + } + + .workflow-agent-result, + .workflow-agent-result-preview { + display: block; + margin-top: 6px; + } + + .workflow-agent-result-preview { + color: var(--text-muted); + font-size: 0.9em; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -17458,7 +17606,9 @@ 'bash-input': { id: 'bash-input', content: 'πŸ’» Bash Input', style: 'background-color: #e8eaf6;' }, 'bash-output': { id: 'bash-output', content: 'πŸ“„ Bash Output', style: 'background-color: #efebe9;' }, 'teammate': { id: 'teammate', content: 'πŸ‘₯ Teammate', style: 'background-color: #e8f2fd;' }, - 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' } + 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' }, + 'workflow_phase': { id: 'workflow_phase', content: '🧩 Workflow Phase', style: 'background-color: #fff8e1;' }, + 'workflow_agent': { id: 'workflow_agent', content: 'πŸ€– Workflow Agent', style: 'background-color: #f3e5f5;' } }; // Build timeline data from messages @@ -17517,6 +17667,15 @@ // they'd land in the `user` group rather than getting // their own πŸ”„ Async result row in the timeline. messageType = 'task-notification'; + } else if (classList.includes('workflow_phase')) { + // Spliced dynamic-workflow phase/agent cards (#174 PR3) + // carry the `tool_use` class for filter visibility, so β€” + // like `teammate`/`task-notification` β€” they need an + // explicit branch before the generic `.find` below, else + // they'd be swept into the πŸ› οΈ Tool Use lane. + messageType = 'workflow_phase'; + } else if (classList.includes('workflow_agent')) { + messageType = 'workflow_agent'; } else { // Look for standard message types messageType = classList.find(cls => @@ -21553,6 +21712,48 @@ .workflow-script { margin-top: 4px; } + + /* Spliced dynamic-workflow run tree (#174 PR3): phase + agent cards. */ + .workflow-phase-meta, + .workflow-agent-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + font-size: 0.85em; + color: var(--text-muted); + } + + .workflow-phase-detail { + color: var(--text-secondary); + } + + .workflow-phase-count, + .workflow-agent-tokens, + .workflow-agent-tools { + font-size: 0.95em; + color: var(--text-muted); + } + + .workflow-agent-model { + font-family: var(--font-mono, monospace); + color: var(--text-secondary); + } + + .workflow-agent-state { + font-style: italic; + } + + .workflow-agent-result, + .workflow-agent-result-preview { + display: block; + margin-top: 6px; + } + + .workflow-agent-result-preview { + color: var(--text-muted); + font-size: 0.9em; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -23459,7 +23660,9 @@ 'bash-input': { id: 'bash-input', content: 'πŸ’» Bash Input', style: 'background-color: #e8eaf6;' }, 'bash-output': { id: 'bash-output', content: 'πŸ“„ Bash Output', style: 'background-color: #efebe9;' }, 'teammate': { id: 'teammate', content: 'πŸ‘₯ Teammate', style: 'background-color: #e8f2fd;' }, - 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' } + 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' }, + 'workflow_phase': { id: 'workflow_phase', content: '🧩 Workflow Phase', style: 'background-color: #fff8e1;' }, + 'workflow_agent': { id: 'workflow_agent', content: 'πŸ€– Workflow Agent', style: 'background-color: #f3e5f5;' } }; // Build timeline data from messages @@ -23518,6 +23721,15 @@ // they'd land in the `user` group rather than getting // their own πŸ”„ Async result row in the timeline. messageType = 'task-notification'; + } else if (classList.includes('workflow_phase')) { + // Spliced dynamic-workflow phase/agent cards (#174 PR3) + // carry the `tool_use` class for filter visibility, so β€” + // like `teammate`/`task-notification` β€” they need an + // explicit branch before the generic `.find` below, else + // they'd be swept into the πŸ› οΈ Tool Use lane. + messageType = 'workflow_phase'; + } else if (classList.includes('workflow_agent')) { + messageType = 'workflow_agent'; } else { // Look for standard message types messageType = classList.find(cls => @@ -27821,6 +28033,48 @@ .workflow-script { margin-top: 4px; } + + /* Spliced dynamic-workflow run tree (#174 PR3): phase + agent cards. */ + .workflow-phase-meta, + .workflow-agent-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + font-size: 0.85em; + color: var(--text-muted); + } + + .workflow-phase-detail { + color: var(--text-secondary); + } + + .workflow-phase-count, + .workflow-agent-tokens, + .workflow-agent-tools { + font-size: 0.95em; + color: var(--text-muted); + } + + .workflow-agent-model { + font-family: var(--font-mono, monospace); + color: var(--text-secondary); + } + + .workflow-agent-state { + font-style: italic; + } + + .workflow-agent-result, + .workflow-agent-result-preview { + display: block; + margin-top: 6px; + } + + .workflow-agent-result-preview { + color: var(--text-muted); + font-size: 0.9em; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -29727,7 +29981,9 @@ 'bash-input': { id: 'bash-input', content: 'πŸ’» Bash Input', style: 'background-color: #e8eaf6;' }, 'bash-output': { id: 'bash-output', content: 'πŸ“„ Bash Output', style: 'background-color: #efebe9;' }, 'teammate': { id: 'teammate', content: 'πŸ‘₯ Teammate', style: 'background-color: #e8f2fd;' }, - 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' } + 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' }, + 'workflow_phase': { id: 'workflow_phase', content: '🧩 Workflow Phase', style: 'background-color: #fff8e1;' }, + 'workflow_agent': { id: 'workflow_agent', content: 'πŸ€– Workflow Agent', style: 'background-color: #f3e5f5;' } }; // Build timeline data from messages @@ -29786,6 +30042,15 @@ // they'd land in the `user` group rather than getting // their own πŸ”„ Async result row in the timeline. messageType = 'task-notification'; + } else if (classList.includes('workflow_phase')) { + // Spliced dynamic-workflow phase/agent cards (#174 PR3) + // carry the `tool_use` class for filter visibility, so β€” + // like `teammate`/`task-notification` β€” they need an + // explicit branch before the generic `.find` below, else + // they'd be swept into the πŸ› οΈ Tool Use lane. + messageType = 'workflow_phase'; + } else if (classList.includes('workflow_agent')) { + messageType = 'workflow_agent'; } else { // Look for standard message types messageType = classList.find(cls => @@ -33934,6 +34199,48 @@ .workflow-script { margin-top: 4px; } + + /* Spliced dynamic-workflow run tree (#174 PR3): phase + agent cards. */ + .workflow-phase-meta, + .workflow-agent-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + font-size: 0.85em; + color: var(--text-muted); + } + + .workflow-phase-detail { + color: var(--text-secondary); + } + + .workflow-phase-count, + .workflow-agent-tokens, + .workflow-agent-tools { + font-size: 0.95em; + color: var(--text-muted); + } + + .workflow-agent-model { + font-family: var(--font-mono, monospace); + color: var(--text-secondary); + } + + .workflow-agent-state { + font-style: italic; + } + + .workflow-agent-result, + .workflow-agent-result-preview { + display: block; + margin-top: 6px; + } + + .workflow-agent-result-preview { + color: var(--text-muted); + font-size: 0.9em; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -35840,7 +36147,9 @@ 'bash-input': { id: 'bash-input', content: 'πŸ’» Bash Input', style: 'background-color: #e8eaf6;' }, 'bash-output': { id: 'bash-output', content: 'πŸ“„ Bash Output', style: 'background-color: #efebe9;' }, 'teammate': { id: 'teammate', content: 'πŸ‘₯ Teammate', style: 'background-color: #e8f2fd;' }, - 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' } + 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' }, + 'workflow_phase': { id: 'workflow_phase', content: '🧩 Workflow Phase', style: 'background-color: #fff8e1;' }, + 'workflow_agent': { id: 'workflow_agent', content: 'πŸ€– Workflow Agent', style: 'background-color: #f3e5f5;' } }; // Build timeline data from messages @@ -35899,6 +36208,15 @@ // they'd land in the `user` group rather than getting // their own πŸ”„ Async result row in the timeline. messageType = 'task-notification'; + } else if (classList.includes('workflow_phase')) { + // Spliced dynamic-workflow phase/agent cards (#174 PR3) + // carry the `tool_use` class for filter visibility, so β€” + // like `teammate`/`task-notification` β€” they need an + // explicit branch before the generic `.find` below, else + // they'd be swept into the πŸ› οΈ Tool Use lane. + messageType = 'workflow_phase'; + } else if (classList.includes('workflow_agent')) { + messageType = 'workflow_agent'; } else { // Look for standard message types messageType = classList.find(cls => @@ -40108,6 +40426,48 @@ .workflow-script { margin-top: 4px; } + + /* Spliced dynamic-workflow run tree (#174 PR3): phase + agent cards. */ + .workflow-phase-meta, + .workflow-agent-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + font-size: 0.85em; + color: var(--text-muted); + } + + .workflow-phase-detail { + color: var(--text-secondary); + } + + .workflow-phase-count, + .workflow-agent-tokens, + .workflow-agent-tools { + font-size: 0.95em; + color: var(--text-muted); + } + + .workflow-agent-model { + font-family: var(--font-mono, monospace); + color: var(--text-secondary); + } + + .workflow-agent-state { + font-style: italic; + } + + .workflow-agent-result, + .workflow-agent-result-preview { + display: block; + margin-top: 6px; + } + + .workflow-agent-result-preview { + color: var(--text-muted); + font-size: 0.9em; + } /* Session navigation styles */ .navigation { background-color: var(--bg-neutral); @@ -42014,7 +42374,9 @@ 'bash-input': { id: 'bash-input', content: 'πŸ’» Bash Input', style: 'background-color: #e8eaf6;' }, 'bash-output': { id: 'bash-output', content: 'πŸ“„ Bash Output', style: 'background-color: #efebe9;' }, 'teammate': { id: 'teammate', content: 'πŸ‘₯ Teammate', style: 'background-color: #e8f2fd;' }, - 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' } + 'task-notification': { id: 'task-notification', content: 'πŸ”„ Async result', style: 'background-color: #e8f2fd;' }, + 'workflow_phase': { id: 'workflow_phase', content: '🧩 Workflow Phase', style: 'background-color: #fff8e1;' }, + 'workflow_agent': { id: 'workflow_agent', content: 'πŸ€– Workflow Agent', style: 'background-color: #f3e5f5;' } }; // Build timeline data from messages @@ -42073,6 +42435,15 @@ // they'd land in the `user` group rather than getting // their own πŸ”„ Async result row in the timeline. messageType = 'task-notification'; + } else if (classList.includes('workflow_phase')) { + // Spliced dynamic-workflow phase/agent cards (#174 PR3) + // carry the `tool_use` class for filter visibility, so β€” + // like `teammate`/`task-notification` β€” they need an + // explicit branch before the generic `.find` below, else + // they'd be swept into the πŸ› οΈ Tool Use lane. + messageType = 'workflow_phase'; + } else if (classList.includes('workflow_agent')) { + messageType = 'workflow_agent'; } else { // Look for standard message types messageType = classList.find(cls => diff --git a/test/test_data/workflow_basic/11110000-0000-4000-8000-000000000001.jsonl b/test/test_data/workflow_basic/11110000-0000-4000-8000-000000000001.jsonl index 40ac3075..50d6f44e 100644 --- a/test/test_data/workflow_basic/11110000-0000-4000-8000-000000000001.jsonl +++ b/test/test_data/workflow_basic/11110000-0000-4000-8000-000000000001.jsonl @@ -1,3 +1,3 @@ {"type": "user", "uuid": "u0000001", "parentUuid": null, "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "11110000-0000-4000-8000-000000000001", "version": "2.1.2", "timestamp": "2026-06-04T10:00:00.000Z", "message": {"role": "user", "content": [{"type": "text", "text": "Review the diff with a workflow."}]}} {"type": "assistant", "uuid": "a0000001", "parentUuid": "u0000001", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "11110000-0000-4000-8000-000000000001", "version": "2.1.2", "timestamp": "2026-06-04T10:00:00.000Z", "message": {"id": "msg_a0000001", "type": "message", "role": "assistant", "model": "claude-opus-4-8", "stop_reason": "end_turn", "content": [{"type": "text", "text": "Launching a review workflow."}, {"type": "tool_use", "id": "toolu_wf01", "name": "Workflow", "input": {"script": "export const meta = {\n name: 'demo-review',\n description: 'Review changed files across dimensions',\n phases: [{ title: 'Map' }, { title: 'Synthesize' }],\n}\nphase('Map')\nconst findings = await parallel(DIMS.map(d => () => agent(d.prompt)))\nphase('Synthesize')\nreturn await agent('Merge: ' + JSON.stringify(findings))\n"}}], "usage": {"input_tokens": 5, "output_tokens": 5}}} -{"type": "user", "uuid": "u0000002", "parentUuid": "a0000001", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "11110000-0000-4000-8000-000000000001", "version": "2.1.2", "timestamp": "2026-06-04T10:00:00.000Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_wf01", "content": "{\"status\":\"async_launched\",\"runId\":\"wf_demo01\"}"}]}, "toolUseResult": {"isAsync": true, "status": "async_launched", "runId": "wf_demo01", "taskId": "task_demo01", "transcriptDir": "11110000-0000-4000-8000-000000000001/subagents/workflows/wf_demo01", "scriptPath": "11110000-0000-4000-8000-000000000001/workflows/scripts/demo-review-wf_demo01.js"}} +{"type": "user", "uuid": "u0000002", "parentUuid": "a0000001", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "11110000-0000-4000-8000-000000000001", "version": "2.1.2", "timestamp": "2026-06-04T10:00:00.000Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_wf01", "content": "Workflow launched in background. Task ID: task_demo01\nSummary: Review the diff across dimensions."}]}, "toolUseResult": {"isAsync": true, "status": "async_launched", "runId": "wf_demo01", "taskId": "task_demo01", "transcriptDir": "11110000-0000-4000-8000-000000000001/subagents/workflows/wf_demo01", "scriptPath": "11110000-0000-4000-8000-000000000001/workflows/scripts/demo-review-wf_demo01.js"}} diff --git a/test/test_workflow_browser.py b/test/test_workflow_browser.py new file mode 100644 index 00000000..1466d181 --- /dev/null +++ b/test/test_workflow_browser.py @@ -0,0 +1,84 @@ +"""Live-browser tests for the spliced dynamic-workflow run tree (#174 PR3). + +The phase/agent cards are synthesized nodes attached via ``.children`` on the +same nested DOM the rest of the transcript uses (PR0 / #191), so the existing +fold machine must drive them: folding a Workflow *phase* hides its agent +children (and their grafted side-channel transcripts) exactly like any other +foldable node β€” provided the synthetic nodes carry the fold-state fields the +splice sets. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from playwright.sync_api import Page + +from claude_code_log.converter import load_directory_transcripts +from claude_code_log.html.renderer import generate_html + +WORKFLOW_DIR = Path(__file__).parent / "test_data" / "workflow_basic" + + +def _render(tmp_path: Path) -> str: + """Render the workflow_basic directory (Workflow tool_use with a spliced + phase/agent sub-tree) to an HTML file and return a ``file://`` URL.""" + msgs, tree = load_directory_transcripts(WORKFLOW_DIR, silent=True) + html = generate_html(msgs, session_tree=tree) + out = tmp_path / "workflow.html" + out.write_text(html, encoding="utf-8") + return f"file://{out}" + + +class TestWorkflowPhaseFold: + """A spliced ``workflow_phase`` card drives its ``workflow_agent`` children + through the shared nested-DOM fold machine: clicking its fold control + toggles the agents' visibility, and a second click restores it. + + (Deeply-nested subtrees start folded by default, so this asserts a *toggle* + rather than a fixed initial state β€” proving the fold machine operates on the + synthetic nodes, which requires the fold-state fields the splice sets.)""" + + @pytest.mark.browser + def test_phase_fold_control_toggles_agents( + self, page: Page, tmp_path: Path + ) -> None: + page.goto(_render(tmp_path)) + page.wait_for_timeout(300) + + result = page.evaluate( + """() => { + // A workflow_phase card whose sibling .children holds an agent. + // (Non-session/non-user nodes start folded, so we assert the + // fold control TOGGLES the phase's own children container β€” + // independent of any ancestor's fold state, i.e. offsetParent.) + const phases = Array.from( + document.querySelectorAll('.message.workflow_phase[data-message-id]')); + for (const phase of phases) { + const cc = phase.parentElement && + phase.parentElement.querySelector(':scope > .children'); + const agent = cc && cc.querySelector('.message.workflow_agent'); + const mid = phase.getAttribute('data-message-id'); + const bar = document.querySelector( + `.fold-bar[data-message-id="${mid}"] ` + + `.fold-bar-section.fold-one-level`); + if (!agent || !bar || !cc) continue; + + const hidden = () => cc.style.display === 'none'; + const d0 = hidden(); + bar.click(); + const d1 = hidden(); + bar.click(); + const d2 = hidden(); + return { found: true, phaseId: mid, d0, d1, d2 }; + } + return { found: false }; + }""" + ) + + assert result.get("found"), "no workflow_phase with agents + fold bar found" + # One click flips the children container's hidden state; a second + # restores it β€” the fold machine operates on the synthetic phase node. + assert result["d1"] != result["d0"] + assert result["d2"] == result["d0"] diff --git a/test/test_workflow_rendering.py b/test/test_workflow_rendering.py index 6e3736b3..b19bd7b8 100644 --- a/test/test_workflow_rendering.py +++ b/test/test_workflow_rendering.py @@ -134,6 +134,89 @@ def test_bracket_in_phase_detail_does_not_truncate_phases(self) -> None: def test_no_meta_block_returns_empty(self) -> None: assert parse_workflow_meta("const x = 1\nawait agent('hi')\n") == ("", "", []) + +_JS_META = ( + "export const meta = {\n" + " name: 'js-name',\n" + " description: 'js-desc',\n" + " phases: [{ title: 'Map' }],\n" + "}\n" +) + + +class TestSnapshotFirstHeader: + """PR3 / cboos refinement: the header prefers the authoritative + .json snapshot over the best-effort JS-meta regex, warns on JS-meta + drift, and falls back to the regex for a running (no-snapshot) workflow.""" + + def _run(self, **kw): + from claude_code_log.workflow import WorkflowPhase, WorkflowRun + + return WorkflowRun( + run_id="r", + workflow_name=kw.get("name", "SNAP-NAME"), + has_snapshot=kw.get("has_snapshot", True), + phases=kw.get( + "phases", + [ + WorkflowPhase(index=0, title="Alpha"), + WorkflowPhase(index=1, title="Beta"), + ], + ), + ) + + def test_snapshot_name_and_phases_win_description_from_js(self) -> None: + from claude_code_log.workflow import resolve_workflow_header + + name, desc, phases = resolve_workflow_header(self._run(), _JS_META) + assert name == "SNAP-NAME" # snapshot workflowName wins over JS name + assert phases == ["Alpha", "Beta"] # snapshot phases win over JS phases + assert desc == "js-desc" # description has no snapshot source β†’ JS + + def test_no_snapshot_falls_back_to_js(self) -> None: + from claude_code_log.workflow import resolve_workflow_header + + assert resolve_workflow_header(None, _JS_META) == ( + "js-name", + "js-desc", + ["Map"], + ) + + def test_drift_warning_when_js_meta_misses(self, caplog) -> None: + import logging + + from claude_code_log.workflow import resolve_workflow_header + + with caplog.at_level(logging.WARNING, logger="claude_code_log.workflow"): + # snapshot has name+phases, but the script has no `export const meta` + resolve_workflow_header(self._run(), "const x = 1\n") + assert any("may have drifted" in r.message for r in caplog.records) + + +class TestWorkflowRunLinkage: + """PR3 step 1-2: a parsed run links to its Workflow tool_use by taskId on a + directory load, so the formatter can render snapshot-first.""" + + def test_run_links_to_tool_use_input(self) -> None: + from claude_code_log.converter import load_directory_transcripts + from claude_code_log.models import ToolUseMessage, WorkflowToolInput + from claude_code_log.renderer import generate_template_messages + + msgs, tree = load_directory_transcripts(TRUNK.parent, silent=True) + assert "wf_demo01" in tree.workflow_runs + _roots, _nav, ctx = generate_template_messages(msgs, session_tree=tree) + linked = [ + tm.content.input.workflow_run + for tm in ctx.messages + if tm is not None + and isinstance(tm.content, ToolUseMessage) + and tm.content.tool_name == "Workflow" + and isinstance(tm.content.input, WorkflowToolInput) + ] + assert len(linked) == 1 + assert linked[0] is not None + assert linked[0].run_id == "wf_demo01" + def test_decoy_local_meta_ignored_for_exported_block(self) -> None: # CR #205: only the EXPORTED `meta` declaration is the header source; # a non-export local `meta = {...}` before it must not be mis-parsed. @@ -175,3 +258,223 @@ def test_header_neutralizes_html_tags(self) -> None: assert "" not in header # Neutralized text still readable in the header. assert "alert(1)" in header + + +class TestWorkflowRunSplice: + """PR3 step 3: the parsed WorkflowRun tree is spliced under its Workflow + tool_use node β€” phases β†’ agents β†’ each agent's side-channel transcript β€” + on the nested DOM, in both HTML and Markdown.""" + + def _tree(self): + from claude_code_log.converter import load_directory_transcripts + from claude_code_log.models import ToolUseMessage + from claude_code_log.renderer import generate_template_messages + + msgs, tree = load_directory_transcripts(TRUNK.parent, silent=True) + _roots, _nav, ctx = generate_template_messages(msgs, session_tree=tree) + host = next( + tm + for tm in ctx.messages + if tm is not None + and isinstance(tm.content, ToolUseMessage) + and tm.content.tool_name == "Workflow" + ) + return host, ctx + + def test_phases_and_agents_nested_under_tool_use(self) -> None: + host, _ctx = self._tree() + # Two phases (Map, Synthesize) attached to the Workflow tool_use. + from claude_code_log.models import WorkflowPhaseMessage + + phases = [c for c in host.children if c.type == "workflow_phase"] + assert len(phases) == 2 + titles = [ + c.content.title + for c in phases + if isinstance(c.content, WorkflowPhaseMessage) + ] + assert titles == ["Map", "Synthesize"] + # Map has 2 agents, Synthesize 1; all are workflow_agent children. + agents_by_phase = [ + [c for c in p.children if c.type == "workflow_agent"] for p in phases + ] + assert [len(a) for a in agents_by_phase] == [2, 1] + + def test_agent_sidechannel_grafted_beneath_agent(self) -> None: + host, _ctx = self._tree() + first_phase = next(c for c in host.children if c.type == "workflow_phase") + first_agent = next( + c for c in first_phase.children if c.type == "workflow_agent" + ) + # The agent's 3 side-channel entries (user, assistant, assistant) are + # grafted as its children; the assistant's tool_use nests one deeper. + child_types = [c.type for c in first_agent.children] + assert child_types == ["user", "assistant", "assistant"] + assert any( + gc.type == "tool_use" for c in first_agent.children for gc in c.children + ) + + def test_spliced_indices_unique_and_monotonic(self) -> None: + _host, ctx = self._tree() + indices = [tm.message_index for tm in ctx.messages if tm is not None] + assert len(indices) == len(set(indices)) # no collisions across the splice + + def test_html_renders_phase_and_agent_cards(self) -> None: + from claude_code_log.converter import load_directory_transcripts + + msgs, tree = load_directory_transcripts(TRUNK.parent, silent=True) + html = generate_html(msgs, session_tree=tree) + # Rendered-card markers (hyphenated) β€” distinct from the underscore + # `workflow_phase`/`workflow_agent` literals the timeline JS always + # carries, so these prove the cards actually rendered. + assert "workflow-phase-meta" in html and "workflow-agent-meta" in html + assert "Phase: Map" in html and "Phase: Synthesize" in html + assert "review:loader" in html and "review:hierarchy" in html + # StructuredOutput dict result is JSON-highlighted in the agent card. + assert "workflow-agent-result" in html + assert "Discovery glob misses" in html # result content surfaced + + def test_markdown_renders_phase_and_agent_tree(self) -> None: + from claude_code_log.converter import load_directory_transcripts + + msgs, tree = load_directory_transcripts(TRUNK.parent, silent=True) + md = MarkdownRenderer().generate(msgs, session_tree=tree) + assert "Phase: Map" in md and "Phase: Synthesize" in md + assert "review:loader" in md + # Dict result fenced as JSON; the string-result agent's markdown body + # ("## Plan") renders directly. + assert '"area": "loader"' in md + assert "Land parsing first" in md + + def test_list_shaped_agent_result_json_highlighted_in_both_formats(self) -> None: + # CR #210: a list-shaped StructuredOutput result must be JSON-highlighted + # (HTML) / JSON-fenced (Markdown) just like a dict β€” render_async_result_body's + # `{"` heuristic would skip a `[...]` payload, so the HTML formatter must + # JSON-render dict AND list directly. Guards against HTML/Markdown divergence. + from claude_code_log.html.tool_formatters import format_workflow_agent_content + from claude_code_log.markdown.renderer import MarkdownRenderer + from claude_code_log.models import MessageMeta, WorkflowAgentMessage + + content = WorkflowAgentMessage( + meta=MessageMeta.empty(), + label="lister", + result=[{"area": "loader"}, {"area": "hierarchy"}], + ) + html = format_workflow_agent_content(content) + assert "highlight" in html # Pygments JSON highlight, not markdown + assert "workflow-agent-result" in html + # Content present (Pygments wraps tokens in s, so check tokens, not + # a contiguous substring). + assert "area" in html and "loader" in html and "hierarchy" in html + + md = MarkdownRenderer().format_WorkflowAgentMessage( + content, + None, # type: ignore[arg-type] # message unused by this formatter + ) + assert "```json" in md + assert '"area": "loader"' in md + + def test_non_workflow_transcript_has_no_spliced_nodes(self) -> None: + # The splice is gated on a linked workflow_run, so an ordinary + # transcript must yield no workflow_phase / workflow_agent tree nodes. + # (Asserting on the rendered tree, not on the HTML string β€” the timeline + # JS embeds the `workflow_phase`/`workflow_agent` literals on every + # page regardless of content.) + from claude_code_log.converter import load_transcript + from claude_code_log.renderer import generate_template_messages + + other = Path(__file__).parent / "test_data" / "representative_messages.jsonl" + if not other.is_file(): + import pytest + + pytest.skip("no representative fixture available") + _roots, _nav, ctx = generate_template_messages(load_transcript(other)) + types = {tm.type for tm in ctx.messages if tm is not None} + assert "workflow_phase" not in types + assert "workflow_agent" not in types + + +def _has_workflow_tool_use(entry) -> bool: + content = getattr(getattr(entry, "message", None), "content", None) + return isinstance(content, list) and any( + getattr(i, "type", None) == "tool_use" and getattr(i, "name", "") == "Workflow" + for i in content + ) + + +class TestSingleFileWorkflowRender: + """PR3: a lone ``.jsonl`` (cboos's natural usage, + ``claude-code-log .jsonl --detail high``) discovers the sibling + ``/subagents/workflows/`` runs and splices the tree, just like a + directory load.""" + + def test_single_file_html_shows_workflow_tree(self, tmp_path: Path) -> None: + from claude_code_log.converter import convert_jsonl_to + from claude_code_log.models import DetailLevel + + out = tmp_path / "single.html" + convert_jsonl_to( + "html", + TRUNK, + output_path=out, + use_cache=False, + update_cache=False, + silent=True, + detail=DetailLevel.HIGH, + ) + html = out.read_text(encoding="utf-8", errors="replace") + # Same rendered-card markers as the directory path. + assert "workflow-phase-meta" in html and "workflow-agent-meta" in html + assert "Phase: Map" in html and "review:loader" in html + + def test_load_session_workflow_runs_finds_sibling_run(self) -> None: + from claude_code_log.workflow import load_session_workflow_runs + + runs = load_session_workflow_runs(TRUNK, silent=True) + assert [r.run_id for r in runs] == ["wf_demo01"] + + +class TestWorkflowPaginationBoundary: + """PR3: run↔tool_use linkage is resolved at full-session scope (stored on + ``SessionTree.workflow_links``) BEFORE pagination, so a Workflow tool_use + still links to its run when its tool_result is on a different page.""" + + def test_links_via_precomputed_map_without_tool_result_in_slice(self) -> None: + from claude_code_log.converter import load_directory_transcripts + from claude_code_log.models import ToolUseMessage + from claude_code_log.renderer import generate_template_messages + + msgs, tree = load_directory_transcripts(TRUNK.parent, silent=True) + assert tree.workflow_links, "links map should be built at full scope" + + # Slice up to & including the entry holding the Workflow tool_use β€” this + # EXCLUDES the later tool_result entry, mimicking a page boundary where + # tool_use is the last message of a page and tool_result the first of + # the next. + pos = next(i for i, e in enumerate(msgs) if _has_workflow_tool_use(e)) + page = msgs[: pos + 1] + + def _host(messages, session_tree): + _r, _n, ctx = generate_template_messages( + messages, session_tree=session_tree + ) + return next( + tm + for tm in ctx.messages + if tm is not None + and isinstance(tm.content, ToolUseMessage) + and tm.content.tool_name == "Workflow" + ) + + # WITH the precomputed map: splice fires despite no tool_result in slice. + host = _host(page, tree) + assert any(c.type == "workflow_phase" for c in host.children), ( + "full-scope links map should link a cross-page tool_use" + ) + + # WITHOUT it (clear the map β†’ per-page fallback): the fallback scan can't + # find a tool_result in this slice, so no splice β€” proving the map is + # what enables cross-page linkage. + tree.workflow_links = {} + host_nolink = _host(page, tree) + assert not any(c.type == "workflow_phase" for c in host_nolink.children) diff --git a/work/dynamic-workflow-pr3-design.md b/work/dynamic-workflow-pr3-design.md new file mode 100644 index 00000000..dc068e1f --- /dev/null +++ b/work/dynamic-workflow-pr3-design.md @@ -0,0 +1,272 @@ +# PR3 design β€” render the WorkflowRun tree (issue #174, final PR) + +Branch: `dev/workflow-tree-render` off main `af7dc29` (PR0+PR1+PR2 landed). +Scope: splice the parsed `WorkflowRun` (phases β†’ agents β†’ each agent's +side-channel transcript) into the message tree at the Workflow tool_use/result +site, on PR0's nested DOM; + snapshot-first header refinement. + +## Architecture decisions (locked) + +**Strategy B β€” self-contained sub-tree, spliced post-`_build_message_tree`.** +Do NOT route workflow agents through `_integrate_agent_entries` / +`_build_message_hierarchy` / `_relocate_subagent_blocks` (the 0–5 level-stack +can't express phaseβ†’agentβ†’sidechain and the blast radius on non-workflow +rendering is high). Instead build the workflow sub-tree separately and attach +it as `.children` of the Workflow tool_use node after the main tree is built. + +### Step 1 β€” load + link (foundation) +- `converter.load_directory_transcripts`: after the tree is built, call PR1's + `load_workflow_runs(directory_path)` and stash `{run_id: WorkflowRun}` on the + `SessionTree` (new field `workflow_runs`, default `{}`). +- `renderer.generate_template_messages`: read `session_tree.workflow_runs`. +- Link each run to its Workflow tool_use: the `runId` is on the tool_RESULT's + `toolUseResult` (`status: async_launched`), same anchor `_link_async_notifications` + uses. Find the Workflow `ToolUseMessage` paired with that result; stash the + `WorkflowRun` on it (e.g. `ToolUseMessage.workflow_run`). + +### Step 2 β€” snapshot-first header (cboos refinement) +- `format_workflow_input` / `MarkdownRenderer.format_WorkflowToolInput`: when a + linked `WorkflowRun` with a snapshot is present, use its `workflow_name` + + `phases[].title` for the header (authoritative); else fall back to the + JS-`meta` regex (`parse_workflow_meta`) for the running/no-snapshot case. +- **Warn** when the JS-meta parse misses expected fields (format-drift signal). +- **Back-fill**: prefer JSON when available; regex is the running-only fallback. + +### Step 3 β€” tree splice (the core) +New `MessageContent` subclasses (in models.py) so they thread into the tree and +dispatch via `format_`: +- `WorkflowPhaseMessage` (title, detail, counts) β†’ phase-header card. +- `WorkflowAgentMessage` (label, model, state, tokens, tool_calls, result) β†’ + agent card with its result (StructuredOutput dict pygmentized / string md). +Splice pass (after `_build_message_tree`, before render): +- For each Workflow tool_use node with a linked run, synthesize a + `WorkflowPhaseMessage` TemplateMessage per phase; under each, a + `WorkflowAgentMessage` per agent; under each agent, the agent's side-channel + entries rendered into TemplateMessages (reuse the factoryβ†’TemplateMessage path + on `agent.entries`) nested as children. Attach phase nodes as `.children` of + the tool_use (or tool_result) node. +- Assign `message_index` to synthetic nodes from a high non-colliding counter; + set `.children` directly (we're past `_build_message_tree`, so ancestry isn't + needed β€” just populate `.children` + `message_id`/`should_render`). +- Timeline parity: add the new CSS classes to `components/timeline.html` + detection. + +### Verification +- New fixture already exists: `test/test_data/workflow_basic` (PR1). +- Tests: run discovered+linked; phases/agents/sidechains nested under the + tool_use; header snapshot-first + warn + fallback; HTML + Markdown. +- Snapshot regen serially (`-n0`); review diff. +- `just ci` green (ty warnings-only/exit-0). + +## Open risk +- `message_index` allocation for synthetic nodes must not collide with existing + indices (anchors/timeline). Use a SINGLE monotonic allocator that persists and + advances across ALL workflow splices in the session (a session may have + several / concurrent Workflow tool_uses) β€” NOT `max(original)+1` recomputed + per workflow, which would collide run #2's nodes with run #1's grafted ones. + See step D.1. +- Side-channel entries β†’ TemplateMessages: simplest is a recursive + `generate_template_messages(agent.entries)` and graft its non-session-header + nodes; verify it doesn't emit spurious session headers per agent. + +--- + +## STATUS (2026-06-07) β€” steps 1-2 DONE, step 3 NOT STARTED + +Branch `dev/wf-tree-render` (off main `4fe6788` β€” rebased from the original +`dev/workflow-tree-render` which was off the now-stale `af7dc29`; #204/#205/#206/#208 +landed since). The 4 commits carried forward cleanly (no conflicts): +- step 1 β€” load + attach `SessionTree.workflow_runs` + this doc. +- step 2 β€” taskId linkage (`_link_workflow_runs`) + + `resolve_workflow_header` (snapshot-first, warn-on-drift) used by both + renderers; fixture tool_result content fixed to real-data shape. +- step-3 implementation map below. +- (this commit) β€” revalidation deltas below. + +Steps 1-2 verified post-rebase: 19 workflow-rendering tests green. NOT pushed +(keep fresh-PR-auto-CR for when PR3 is whole). + +## REVALIDATION (2026-06-07) β€” plan checked against current main `4fe6788` + +Re-read the live code (renderer.py / html/renderer.py / markdown/renderer.py / +models.py / html/utils.py / timeline.html) before writing step-3 code. Wiring +points all still present; five deltas vs the original (af7dc29-era) plan: + +1. **Allocator β€” use `ctx.register()`, which is *inherently* session-wide + monotonic.** `RenderingContext.register(msg)` does `msg_index = + len(self.messages); message.message_index = msg_index; + self.messages.append(...)`. Registering every synthetic + grafted node + through `ctx.register` therefore yields unique, ever-increasing indices + across ALL workflows in the session automatically β€” no manual `max()+1` + bookkeeping, and cboos's "single monotonic allocator" requirement is met by + construction. **Constraint:** run `_splice_workflow_runs` as the LAST pass in + `generate_template_messages` (after `_link_task_id_consumers`) so the + appended synthetic nodes can't perturb earlier ctx.messages-iterating passes. + +2. **`has_children` and `is_paired` are read-only `@property`s now** + (renderer.py L308/L313): `has_children == bool(self.children)`, + `is_paired` derives from `pair_first/pair_last`. The doc's "set + has_children / is_paired=False directly" is obsolete β€” just populate + `.children`; synthetic nodes have no pair fields so `is_paired` is already + False. Do NOT assign them. + +3. **`should_render` is recomputed at render time** β€” + `HtmlRenderer._annotate_tree_for_render` (html/renderer.py L1348) sets + `should_render = bool(title or html or msg.children)` while walking the tree + by `.children`; MarkdownRenderer._render_message just checks non-empty + title/body. So synthetic nodes need NOT set `should_render`; a non-empty + formatter output (or having children) makes them render. + +4. **Counts are ancestry-based and computed *before* the splice.** + `_mark_messages_with_children` (L2237) walks the flat list via `ancestry` + and runs before `_build_message_tree`. Our splice is post-tree, so we set + `immediate_children_count` / `total_descendants_count` (+ `_by_type`) on the + synthetic nodes with a small bottom-up recursive helper, and add the + subtree's descendant total to the host tool_use node (and propagate the delta + up the tool_use's existing ancestors via their `.children`-less count fields). + Do NOT re-run `_mark_messages_with_children` / `_build_message_tree` β€” the + latter clears every `.children` and rebuilds from ancestry, wiping the splice. + +5. **Render path confirmed: BOTH renderers walk `.children`** and dispatch via + `format_` / `title_` over `type(content).__mro__` + (renderer.py L4346/L4382). HTML: `_annotate_tree_for_render` β†’ recursive + macro. Markdown: `_render_message` recurses `msg.children` (L2016). Title + methods (plain strings) go on the shared base renderer; `format_*` go on + `HtmlRenderer` (delegating to a `format_*_content` helper, the established + pattern) and on `MarkdownRenderer`. + +Current anchor line numbers (verified this session): `_build_message_tree` def +L2291 / call L821; `_link_workflow_runs` def L2718 / call L860; splice goes +after `_link_task_id_consumers` (call L886, before `return` L888). +`CSS_CLASS_REGISTRY` html/utils.py L156; `_get_css_classes_from_content` L196. +`WorkflowToolInput.workflow_run` models.py L1564; `MessageContent.meta` first +(models.py L432); `MessageMeta.empty()` L398. Timeline `messageTypeGroups` +timeline.html L24; detection chain L56-103. + +Fixture (`workflow_basic`, runId `wf_demo01`, has_snapshot): 2 phases +(Mapβ†’Synthesize), 3 agents (ag000001/2 in Map β†’ StructuredOutput dicts; +ag000003 in Synthesize β†’ markdown string). Each `agent-*.jsonl` has 3 entries +(user, assistant, assistant). Drives the splice tests directly. + +## POST-IMPLEMENTATION additions (2026-06-07) β€” cboos decision + +Step 3 (the splice) landed and is green on the directory path (verified +end-to-end on REAL data: the `wf_dd551b62-a1d` run renders 3 phases + 42 agent +cards). Two follow-ups were then folded into PR3 after cboos rendered a single +`.jsonl` and saw no children: + +**Root cause of "no children":** the SINGLE-FILE path +(`claude-code-log .jsonl`) never populated `session_tree.workflow_runs` +(only the directory loader did) β†’ the splice's guard never fired. NOT a +cache/pagination issue. + +**Addition 1 β€” single-file workflow support.** `convert_jsonl_to`'s single-file +branch now calls `workflow.load_session_workflow_runs(.jsonl)` (derives the +sibling `/subagents/workflows/` + `/workflows/.json`, mirroring +the directory loader via the shared `_runs_in_session_dir`), and when runs exist +builds a `SessionTree` carrying `workflow_runs` + `workflow_links`. Gated on +"runs exist" so the common no-workflow single-file render stays byte-identical +(`session_tree` left `None`). + +**Addition 2 β€” pagination-boundary fix.** Run↔tool_use linkage now resolves at +full-session scope BEFORE pagination: `workflow.map_workflow_runs_by_tool_use` +scans the raw entries for each Workflow tool_use's `id` and its paired +tool_result's `Task ID: ` (== `run.task_id`), producing a +`{tool_use_id: WorkflowRun}` map stored on `SessionTree.workflow_links`. +`_link_workflow_runs` PREFERS this map (no tool_result needed in the current +page); the per-render tool_result scan remains the fallback for direct +`generate_template_messages` calls. This makes a tool_use on page N link to its +run even when the tool_result is the first message of page N+1. (Latent on the +real `-main` run β€” its tool_use sits mid-page at paged position 54996 β€” but a +genuine edge bug worth closing.) + +Tests: `TestSingleFileWorkflowRender`, `TestWorkflowPaginationBoundary` in +`test/test_workflow_rendering.py`. + +## Step 3 implementation map (wiring points located β€” build this next) + +**A. Node types** (`models.py`, after `ToolUseMessage` ~L1141, before the +Tool Input Models section): two `@dataclass(MessageContent)` subclasses +(base needs `meta: MessageMeta` first; use `MessageMeta.empty()` for synthetic +nodes; override `message_type`): +- `WorkflowPhaseMessage(title, detail, agent_count)` β†’ `message_type = + "workflow_phase"`. +- `WorkflowAgentMessage(label, model, state, tokens, tool_calls, result, + result_preview)` β†’ `message_type = "workflow_agent"`. + +**B. CSS classes** (`html/utils.py`): add both types to `CSS_CLASS_REGISTRY` +(L62) β†’ `["workflow_phase"]` / `["workflow_agent"]`. `css_class_from_message` +(L125) β†’ `_get_css_classes_from_content` reads the registry, so the cards get +those classes automatically. Add `.workflow_phase` / `.workflow_agent` styling +to `components/message_styles.css`. + +**C. Formatters + titles** β€” dispatch is `format_` / +`title_` via `_dispatch_format` (`renderer.py` L4457) / +`_dispatch_title` (L4471). Add to BOTH `HtmlRenderer` and `MarkdownRenderer`: +- `format_WorkflowPhaseMessage` / `title_WorkflowPhaseMessage` (header card: + title + detail + "N agents"). +- `format_WorkflowAgentMessage` / `title_WorkflowAgentMessage` (label/model/ + state/tokens; body = result β€” dict β†’ JSON-pygmentize (reuse + `render_async_result_body`), str β†’ markdown). + +**D. Splice pass** (`renderer.py`, new `_splice_workflow_runs(ctx)` called +**LAST** in `generate_template_messages` β€” after `_link_task_id_consumers`, the +final link pass β€” so its `ctx.register` appends can't perturb any earlier +`ctx.messages`-iterating pass (see REVALIDATION Β§1). Guard: only splices a +Workflow tool_use whose `input.workflow_run` is set.) + + 1. **Index allocation β€” use `ctx.register(node)`** (see REVALIDATION Β§1). It + assigns `len(ctx.messages)` and appends, so it IS a single session-wide + monotonic allocator by construction (cboos's requirement) β€” every synthetic + + grafted node registered through it gets a unique, ever-increasing index + across ALL workflows in the session, with no manual `max()+1`. Constraint: + splice runs LAST so the appends don't perturb earlier passes. + +Then, for each Workflow tool_use TemplateMessage with a linked run: + 2. For each `run.phases` (or flat `run.agents` when no snapshot): build a + `WorkflowPhaseMessage` TemplateMessage; under it a `WorkflowAgentMessage` + TemplateMessage per agent; under each agent, render `agent.entries` and + graft (see E). Allocate `message_index`/`message_id = d-{idx}` from the + counter for EVERY synthetic + grafted node (re-index to avoid collision). + 3. Set fold-state fields on each synthetic parent (see REVALIDATION Β§2-4): + populate `.children` directly (drives `has_children` property + render); + set `immediate_children_count` / `total_descendants_count` (+ `_by_type`) + via the bottom-up helper. Do NOT touch `has_children` / `is_paired` (read-only + properties) or `should_render` (recomputed by the render walk). + 4. Attach phase nodes as `.children` of the tool_use node (or its paired + tool_result β€” pick the one that reads best; tool_use keeps it next to the + script). Recompute the tool_use's `has_children`/counts. + +**E. Agent side-channel β†’ TemplateMessages**: call +`generate_template_messages(agent.entries)` β†’ take each session-header root's +`.children` (skip the synthetic session header) and graft under the agent node, +RE-INDEXING every grafted node's `message_index`/`message_id` from the counter +(walk `.children` recursively). Verify no spurious per-agent session header +leaks into the output. + +**F. Timeline parity** (`components/timeline.html`): add `workflow_phase` / +`workflow_agent` to `messageTypeGroups` (L24) AND a detection branch in the +classβ†’type chain (L56-95) β€” they carry only their own class, so add explicit +`classList.includes('workflow_phase'|'workflow_agent')` branches before the +generic `.find`. + +**G. Watch-points (main):** +1. Timeline β€” done via B+F (registry class + timeline detection). +2. Fold/unfold β€” the spliced sub-tree uses the SAME nested-DOM `.children` + structure (#191), so the existing fold machine works IFF fold-state fields + (C-step3) are set. Verify fold/unfold of a phase via Playwright. +3. Non-workflow byte-identical β€” the splice is gated on `input.workflow_run` + (only set for directory loads with a run), so non-workflow snapshots must be + unchanged. Re-run snapshot suite; diff should be empty for non-workflow + fixtures (workflow_basic isn't a snapshot fixture). + +**Tests**: extend `test_workflow_rendering.py` β€” directory render of +workflow_basic shows phase/agent cards nested under the Workflow tool_use; each +agent's side-channel entries present beneath it; HTML + Markdown; a fold +Playwright test. Snapshot regen serial (`-n0`); `just ci` green. + +**STATUS β€” DONE.** Aβ†’F all implemented and merged into this branch (built in +that order: Aβ†’Bβ†’C node types/registry/formatters/CSS, then Dβ†’E splice + +re-index, then F timeline + tests). Plus the two POST-IMPLEMENTATION additions +above (single-file support + pagination-boundary fix). `just ci` green; +monk-approved (PR #210). This map is retained as as-built reference.