Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions claude_code_log/html/templates/components/message_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1503,6 +1503,48 @@ details summary {
color: var(--text-secondary);
}

/* Non-completed terminal status chip (failed / killed / ...) — for a run
that launched no agents this is the only failure signal on the page. */
.workflow-status {
font-size: 0.78em;
padding: 1px 8px;
border-radius: 10px;
border: 1px solid var(--error-dimmed);
color: var(--system-error-color);
background-color: var(--error-semi);
}

/* The run's snapshot `error` — typically a JS stack trace, collapsed. */
.workflow-error {
margin-bottom: 8px;
}

.workflow-error > summary {
cursor: pointer;
font-size: 0.85em;
color: var(--system-error-color);
}

.workflow-error > pre {
margin: 4px 0 0;
padding: 6px 8px;
background-color: var(--error-semi);
border-left: 3px solid var(--system-error-color);
overflow-x: auto;
font-size: 0.85em;
}

/* Invocation line — how the workflow was launched, for the non-inline
shapes (saved-workflow name / scriptPath reference / resumed run). */
.workflow-invocation {
display: flex;
flex-wrap: wrap;
gap: 4px 14px;
margin-bottom: 8px;
font-size: 0.85em;
color: var(--text-muted);
}

.workflow-script {
margin-top: 4px;
}
Expand Down
79 changes: 69 additions & 10 deletions claude_code_log/html/tool_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
resolve_memory_body_links,
)
from ..utils import strip_error_tags
from ..workflow import resolve_workflow_header
from ..workflow import resolve_workflow_header, resolve_workflow_script
from ..models import (
AskUserQuestionInput,
AskUserQuestionItem,
Expand Down Expand Up @@ -1471,17 +1471,31 @@ def format_tool_result_content_raw(tool_result: ToolResultContent) -> str:


def format_workflow_input(workflow_input: WorkflowToolInput) -> str:
"""Format a ``Workflow`` tool_use (issue #174): a header from the script's
``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 = resolve_workflow_header(
workflow_input.workflow_run, script
)
"""Format a ``Workflow`` tool_use (issue #174): a header from the effective
script's ``meta`` block (name / description / phase pills), an invocation
line for the non-inline shapes (saved-workflow name / scriptPath / resume,
plus an ``args`` table), then the JavaScript orchestrator source,
syntax-highlighted and collapsible when long.

The source shown is the *effective* script: the inline ``script`` input
when present, else the copy stored in the run's terminal snapshot (the
``scriptPath`` / ``name`` shapes carry no source in the input)."""
run = workflow_input.workflow_run
script = resolve_workflow_script(run, workflow_input.script)
name, description, phases = resolve_workflow_header(run, workflow_input.script)

header_parts: list[str] = []
if name:
header_parts.append(f"<span class='workflow-name'>{escape_html(name)}</span>")
# Non-completed terminal status (e.g. `failed`) is worth a chip right next
# to the name — a failed run may have launched no agents at all, so the
# spliced tree below can be empty and this is the only failure signal.
status = getattr(run, "status", "") or ""
if status and status != "completed":
header_parts.append(
f"<span class='workflow-status workflow-status-{escape_html(status)}'>"
f"{escape_html(status)}</span>"
)
if description:
header_parts.append(
f"<span class='workflow-description'>{escape_html(description)}</span>"
Expand Down Expand Up @@ -1510,8 +1524,53 @@ def format_workflow_input(workflow_input: WorkflowToolInput) -> str:
else ""
)

# Invocation line — how the workflow was launched, for the non-inline
# shapes: saved-workflow name, script file reference, resumed run.
invocation_parts: list[str] = []
if workflow_input.name:
invocation_parts.append(
"<span class='workflow-invocation-item'>saved workflow: "
f"<code>{escape_html(workflow_input.name)}</code></span>"
)
if workflow_input.script_path:
invocation_parts.append(
"<span class='workflow-invocation-item'>script: "
f"<code>{escape_html(workflow_input.script_path)}</code></span>"
)
if workflow_input.resume_from_run_id:
invocation_parts.append(
"<span class='workflow-invocation-item'>resumes: "
f"<code>{escape_html(workflow_input.resume_from_run_id)}</code></span>"
)
invocation = (
f"<div class='workflow-invocation'>{''.join(invocation_parts)}</div>"
if invocation_parts
else ""
)

# The script's input value, rendered by the same hybrid params table as
# tool parameters (wrapped under an explicit "args" key so it can't read
# as the tool's own parameters).
args_html = ""
if workflow_input.args is not None:
args_html = render_params_table({"args": workflow_input.args})

# The run's error (snapshot `error`) — typically a JS stack trace, so
# collapsed by default.
error_html = ""
error = getattr(run, "error", "") or ""
if error:
error_html = (
"<details class='workflow-error'>"
"<summary>error</summary>"
f"<pre>{escape_html(error)}</pre>"
"</details>"
)

prefix = f"{header}{invocation}{args_html}{error_html}"

if not script.strip():
return header
return prefix

body = render_file_content_collapsible(
script,
Expand All @@ -1520,7 +1579,7 @@ def format_workflow_input(workflow_input: WorkflowToolInput) -> str:
line_threshold=12,
preview_line_count=6,
)
return f"{header}{body}"
return f"{prefix}{body}"


# -- Workflow run tree: phase + agent cards (issue #174 PR3) -------------------
Expand Down
43 changes: 38 additions & 5 deletions claude_code_log/markdown/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1078,18 +1078,25 @@ def format_WebFetchInput(self, input: WebFetchInput, _: TemplateMessage) -> str:
def format_WorkflowToolInput(
self, input: WorkflowToolInput, _: TemplateMessage
) -> str:
"""Format → optional one-line meta header + the JS script in a fenced
```js block (issue #174).
"""Format → optional one-line meta header + invocation line (saved
name / scriptPath / resume / args) + the effective JS script in a
fenced ```js block (issue #174).

Registering ``Workflow`` in TOOL_INPUT_MODELS is format-neutral, so
this method is required for parity — without it the Markdown renderer
would emit nothing for the script (a regression from the pre-PR
ToolUseContent ``**script:**`` fallback).

Mirrors the HTML formatter's shape handling: the script shown is the
inline input when present, else the snapshot-stored copy (the
``scriptPath`` / ``name`` invocation shapes carry no source inline).
"""
from ..workflow import resolve_workflow_header
from ..workflow import resolve_workflow_header, resolve_workflow_script

script = input.script or ""
name, description, phases = resolve_workflow_header(input.workflow_run, script)
script = resolve_workflow_script(input.workflow_run, input.script)
name, description, phases = resolve_workflow_header(
input.workflow_run, input.script
)

parts: list[str] = []
header_bits: list[str] = []
Expand All @@ -1099,6 +1106,11 @@ def format_WorkflowToolInput(
# workflow name/description/phase. (The script body itself is fenced.)
if name:
header_bits.append(f"**{_protect_html_tags(name)}**")
# Non-completed terminal status (e.g. `failed`) — a failed run may
# have launched no agents, so this can be the only failure signal.
status = getattr(input.workflow_run, "status", "") or ""
if status and status != "completed":
header_bits.append(f"`{_protect_html_tags(status)}`")
if description:
header_bits.append(_protect_html_tags(description))
header = " — ".join(header_bits)
Expand All @@ -1108,6 +1120,27 @@ def format_WorkflowToolInput(
header = f"{header} {phase_text}" if header else phase_text
if header:
parts.append(header)

invocation_bits: list[str] = []
if input.name:
invocation_bits.append(
f"saved workflow: `{_protect_html_tags(input.name)}`"
)
if input.script_path:
invocation_bits.append(f"script: `{_protect_html_tags(input.script_path)}`")
if input.resume_from_run_id:
invocation_bits.append(
f"resumes: `{_protect_html_tags(input.resume_from_run_id)}`"
)
if invocation_bits:
parts.append("_" + " · ".join(invocation_bits) + "_")
if input.args is not None:
args_json = json.dumps(input.args, indent=2, ensure_ascii=False)
parts.append(f"**args:**\n\n{self._code_fence(args_json, 'json')}")
error = getattr(input.workflow_run, "error", "") or ""
if error:
parts.append(f"**error:**\n\n{self._code_fence(error)}")

if script.strip():
parts.append(self._code_fence(script, "js"))
return "\n\n".join(parts)
Expand Down
18 changes: 14 additions & 4 deletions claude_code_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1617,13 +1617,23 @@ class TaskStopInput(BaseModel):
class WorkflowToolInput(BaseModel):
"""Input parameters for the dynamic ``Workflow`` tool (issue #174).

A Workflow tool_use carries a single field: ``script``, the JavaScript
orchestrator source. Its ``export const meta = {...}`` block (name /
description / phases) is surfaced as a header and the script body is
syntax-highlighted as JavaScript by the formatter.
A Workflow tool_use comes in several invocation shapes: inline ``script``
(the JavaScript orchestrator source), ``scriptPath`` (source in a file on
disk — no source in the input at all), or ``name`` (a saved workflow),
optionally combined with ``args`` (the script's input value) and
``resumeFromRunId`` (resume a prior run). Only the inline shape carries
the source; for the other shapes the renderer falls back to the script
stored in the run's terminal snapshot. The effective script's
``export const meta = {...}`` block (name / description / phases) is
surfaced as a header and the script body is syntax-highlighted as
JavaScript by the formatter.
"""

script: str = ""
script_path: str = Field(default="", alias="scriptPath")
name: str = ""
args: Optional[Any] = None
resume_from_run_id: str = Field(default="", alias="resumeFromRunId")

# Renderer-set (issue #174 PR3): the parsed WorkflowRun linked to this
# tool_use by taskId, when its <runId>.json snapshot was found on disk.
Expand Down
Loading
Loading