diff --git a/claude_code_log/html/templates/components/message_styles.css b/claude_code_log/html/templates/components/message_styles.css
index 22a10671..8983c961 100644
--- a/claude_code_log/html/templates/components/message_styles.css
+++ b/claude_code_log/html/templates/components/message_styles.css
@@ -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;
}
diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py
index f0961382..63c57085 100644
--- a/claude_code_log/html/tool_formatters.py
+++ b/claude_code_log/html/tool_formatters.py
@@ -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,
@@ -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"{escape_html(name)}")
+ # 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""
+ f"{escape_html(status)}"
+ )
if description:
header_parts.append(
f"{escape_html(description)}"
@@ -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(
+ "saved workflow: "
+ f"{escape_html(workflow_input.name)}"
+ )
+ if workflow_input.script_path:
+ invocation_parts.append(
+ "script: "
+ f"{escape_html(workflow_input.script_path)}"
+ )
+ if workflow_input.resume_from_run_id:
+ invocation_parts.append(
+ "resumes: "
+ f"{escape_html(workflow_input.resume_from_run_id)}"
+ )
+ invocation = (
+ f"
{''.join(invocation_parts)}
"
+ 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 = (
+ ""
+ "error
"
+ f"{escape_html(error)}"
+ " "
+ )
+
+ prefix = f"{header}{invocation}{args_html}{error_html}"
+
if not script.strip():
- return header
+ return prefix
body = render_file_content_collapsible(
script,
@@ -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) -------------------
diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py
index 2ff3f2ad..76c39c7a 100644
--- a/claude_code_log/markdown/renderer.py
+++ b/claude_code_log/markdown/renderer.py
@@ -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] = []
@@ -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)
@@ -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)
diff --git a/claude_code_log/models.py b/claude_code_log/models.py
index 31f59f6f..07e1fa4f 100644
--- a/claude_code_log/models.py
+++ b/claude_code_log/models.py
@@ -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 .json snapshot was found on disk.
diff --git a/claude_code_log/workflow.py b/claude_code_log/workflow.py
index 8b7de555..16c554ea 100644
--- a/claude_code_log/workflow.py
+++ b/claude_code_log/workflow.py
@@ -9,6 +9,8 @@
agent-.jsonl per-agent side-channel transcript
agent-.meta.json {"agentType": "workflow-subagent"}
/workflows/.json terminal snapshot: phases + per-agent metadata
+ + the script that ran (+ args, summary,
+ error, run totals)
This module turns that into a :class:`WorkflowRun`. Strategy (D1):
journal-led, ``.json``-enriched — ``journal.jsonl`` is the
@@ -37,10 +39,27 @@
_WF_META_RE = re.compile(
r"export\s+(?:const|let|var)\s+meta\s*=\s*\{(.*?)\n\}", re.DOTALL
)
-_WF_NAME_RE = re.compile(r"\bname\s*:\s*['\"]([^'\"]*)['\"]")
-_WF_DESC_RE = re.compile(r"\bdescription\s*:\s*['\"]([^'\"]*)['\"]")
+# A JS string literal in any of the three quote styles, with backslash-escape
+# support — real meta descriptions contain escaped quotes ('SAM\'s ...'),
+# which a naive [^'\"]* would truncate at.
+_WF_JS_STRING = (
+ r"(?:'((?:[^'\\]|\\.)*)'"
+ r'|"((?:[^"\\]|\\.)*)"'
+ r"|`((?:[^`\\]|\\.)*)`)"
+)
+_WF_NAME_RE = re.compile(r"\bname\s*:\s*" + _WF_JS_STRING)
+_WF_DESC_RE = re.compile(r"\bdescription\s*:\s*" + _WF_JS_STRING)
_WF_PHASES_KEY_RE = re.compile(r"\bphases\s*:")
-_WF_TITLE_RE = re.compile(r"title\s*:\s*['\"]([^'\"]+)['\"]")
+_WF_TITLE_RE = re.compile(r"title\s*:\s*" + _WF_JS_STRING)
+
+
+def _js_string_value(m: "re.Match[str]") -> str:
+ """Extract + unescape the string from a ``_WF_JS_STRING`` match (exactly
+ one of its three alternation groups is non-None). Unescaping is the
+ best-effort ``\\x`` → ``x`` — right for the quotes/backslashes that occur
+ in display strings, and never worse than the truncation it replaces."""
+ value = next((g for g in m.groups()[-3:] if g is not None), "")
+ return re.sub(r"\\(.)", r"\1", value)
def parse_workflow_meta(script: str) -> tuple[str, str, list[str]]:
@@ -56,7 +75,10 @@ def parse_workflow_meta(script: str) -> tuple[str, str, list[str]]:
matched block, so they can't pick up ``name:``/``title:`` elsewhere in
the body. Phase titles are collected from every ``title:`` *after* the
``phases:`` key, so a ``detail`` string containing ``]`` doesn't truncate
- the list. Returns empty values when the block or a field isn't found.
+ the list. String values may use any JS quote style (``'``/``"``/backtick)
+ and may contain backslash-escaped quotes (``'SAM\\'s sweep'`` — observed
+ in real meta blocks). Returns empty values when the block or a field
+ isn't found.
"""
block_m = _WF_META_RE.search(script)
if not block_m:
@@ -67,14 +89,35 @@ def parse_workflow_meta(script: str) -> tuple[str, str, list[str]]:
phases: list[str] = []
phases_key = _WF_PHASES_KEY_RE.search(block)
if phases_key:
- phases = _WF_TITLE_RE.findall(block[phases_key.end() :])
+ phases = [
+ title
+ for m in _WF_TITLE_RE.finditer(block[phases_key.end() :])
+ if (title := _js_string_value(m))
+ ]
return (
- name_m.group(1) if name_m else "",
- desc_m.group(1) if desc_m else "",
+ _js_string_value(name_m) if name_m else "",
+ _js_string_value(desc_m) if desc_m else "",
phases,
)
+def resolve_workflow_script(run: "Optional[WorkflowRun]", script: str) -> str:
+ """Resolve the *effective* orchestrator source for a Workflow tool_use.
+
+ A Workflow invocation carries the script inline only in the ``script``
+ input shape; the ``scriptPath`` / ``name`` / ``resumeFromRunId`` shapes
+ carry no source at all. The terminal ``.json`` snapshot, however,
+ stores the full text that actually ran (its ``script`` field) — so fall
+ back to that. Empty when neither is available (e.g. a still-running
+ ``scriptPath`` invocation with no snapshot yet).
+ """
+ if script:
+ return script
+ if run is not None:
+ return run.script
+ return ""
+
+
def resolve_workflow_header(
run: "Optional[WorkflowRun]", script: str
) -> tuple[str, str, list[str]]:
@@ -85,28 +128,38 @@ def resolve_workflow_header(
``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).
+ ``script`` is the tool_use's inline source, empty for the ``scriptPath`` /
+ ``name`` invocation shapes — the meta parse then runs on the snapshot's
+ stored ``script`` (:func:`resolve_workflow_script`), which is where
+ ``description`` usually comes from for those shapes. When even that yields
+ no description, the snapshot's ``summary`` (the tool's own digest of the
+ description) fills in.
+
+ When a snapshot IS present and a *non-empty* script failed the JS-``meta``
+ parse for a field the snapshot supplies, emit a warning so genuine
+ JS-format drift stays noticeable (we can then adapt the regex). No script
+ text at all is NOT drift — no warning.
"""
- name_js, description, phases_js = parse_workflow_meta(script)
+ effective_script = resolve_workflow_script(run, script)
+ name_js, description, phases_js = parse_workflow_meta(effective_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),
- )
+ if not description:
+ description = run.summary
+ if effective_script:
+ 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
@@ -167,6 +220,15 @@ class WorkflowRun:
the snapshot counts only those that produced a result (retried/abandoned
agents appear in ``agents`` but not in ``agent_count`` — e.g. 42 vs 40 on
the §1 reference run).
+
+ ``script`` is the snapshot's stored copy of the orchestrator source that
+ actually ran — the recovery route for the ``scriptPath`` / ``name``
+ invocation shapes, whose tool_use input carries no source. ``script_path``
+ is where that source lived (the caller's file for a ``scriptPath``
+ invocation, the session-dir persisted copy for an inline one). ``summary``
+ is the tool's digest of the meta description; ``error`` is set for a
+ ``failed`` run (possibly one that died before launching any agent — such a
+ run has a snapshot but no journal, so ``agents`` is empty).
"""
run_id: str
@@ -179,6 +241,14 @@ class WorkflowRun:
total_tokens: Optional[int] = None
agent_count: Optional[int] = None
has_snapshot: bool = False
+ script: str = ""
+ script_path: str = ""
+ args: Any = None
+ summary: str = ""
+ error: str = ""
+ default_model: str = ""
+ duration_ms: Optional[int] = None
+ total_tool_calls: Optional[int] = None
def _read_jsonl(path: Path) -> list[Any]:
@@ -220,6 +290,15 @@ def _as_int(value: Any) -> Optional[int]:
return None
+def _as_str(value: Any) -> str:
+ """Coerce a snapshot value to ``str`` defensively, else ``""``.
+
+ Same posture as :func:`_as_int`: snapshot fields come straight from JSON,
+ so a variant payload could carry a non-string where we expect text.
+ """
+ return value if isinstance(value, str) else ""
+
+
def _parse_journal(path: Path) -> tuple[list[str], dict[str, Any]]:
"""Parse ``journal.jsonl`` into (agent order, {agentId: result}).
@@ -293,14 +372,21 @@ def parse_workflow_run(
"""Parse one workflow run from its ``subagents/workflows//`` dir.
``snapshot_path`` is the optional ``.json`` terminal snapshot.
- Returns ``None`` if there is no ``journal.jsonl`` (not a workflow run).
+ A run that failed before launching any agent leaves a snapshot but NO
+ run dir/journal — that still parses (as a snapshot-only run with an
+ empty ``agents`` list), so the failure stays visible at the tool_use.
+ Returns ``None`` when there is neither a journal nor a snapshot (not a
+ workflow run).
"""
journal = run_dir / "journal.jsonl"
- if not journal.is_file():
+ has_journal = journal.is_file()
+ order: list[str] = []
+ results: dict[str, Any] = {}
+ if has_journal:
+ order, results = _parse_journal(journal)
+ elif snapshot_path is None or not snapshot_path.is_file():
return None
- order, results = _parse_journal(journal)
-
run_id = run_dir.name
task_id = workflow_name = status = ""
total_tokens: Optional[int] = None
@@ -309,6 +395,10 @@ def parse_workflow_run(
phases_meta: list[dict[str, Any]] = []
agent_meta: dict[str, dict[str, Any]] = {}
has_snapshot = False
+ script = script_path = summary = error = default_model = ""
+ run_args: Any = None
+ duration_ms: Optional[int] = None
+ total_tool_calls: Optional[int] = None
if snapshot_path is not None and snapshot_path.is_file():
raw, phases_meta, agent_meta = _load_snapshot(snapshot_path)
@@ -321,6 +411,18 @@ def parse_workflow_run(
total_tokens = _as_int(raw.get("totalTokens"))
agent_count = _as_int(raw.get("agentCount"))
run_result = raw.get("result")
+ script = _as_str(raw.get("script"))
+ script_path = _as_str(raw.get("scriptPath"))
+ run_args = raw.get("args")
+ summary = _as_str(raw.get("summary"))
+ error = _as_str(raw.get("error"))
+ default_model = _as_str(raw.get("defaultModel"))
+ duration_ms = _as_int(raw.get("durationMs"))
+ total_tool_calls = _as_int(raw.get("totalToolCalls"))
+
+ if not has_journal and not has_snapshot:
+ # A snapshot file that failed to load — nothing to build a run from.
+ return None
# Union of journal order with any snapshot-only agent ids (defensive).
all_ids = list(order)
@@ -369,6 +471,14 @@ def parse_workflow_run(
total_tokens=total_tokens,
agent_count=agent_count,
has_snapshot=has_snapshot,
+ script=script,
+ script_path=script_path,
+ args=run_args,
+ summary=summary,
+ error=error,
+ default_model=default_model,
+ duration_ms=duration_ms,
+ total_tool_calls=total_tool_calls,
)
@@ -415,16 +525,29 @@ def discover_workflow_runs(session_dir: Path) -> list[tuple[Path, Optional[Path]
``run_dir`` is ``/subagents/workflows//`` (must
contain ``journal.jsonl``); ``snapshot_path`` is the matching
``/workflows/.json`` if present, else ``None``.
+
+ Also surfaces *snapshot-only* runs: a workflow that died before
+ launching any agent (e.g. a script error on the first line) leaves a
+ ``/workflows/.json`` but no run dir at all. These
+ yield a (non-existent) ``run_dir`` path alongside their snapshot, which
+ :func:`parse_workflow_run` accepts.
"""
base = session_dir / "subagents" / "workflows"
- if not base.is_dir():
- return []
+ snapshots_dir = session_dir / "workflows"
runs: list[tuple[Path, Optional[Path]]] = []
- for run_dir in sorted(base.iterdir()):
- if not run_dir.is_dir() or not (run_dir / "journal.jsonl").is_file():
- continue
- snapshot = session_dir / "workflows" / f"{run_dir.name}.json"
- runs.append((run_dir, snapshot if snapshot.is_file() else None))
+ seen: set[str] = set()
+ if base.is_dir():
+ for run_dir in sorted(base.iterdir()):
+ if not run_dir.is_dir() or not (run_dir / "journal.jsonl").is_file():
+ continue
+ seen.add(run_dir.name)
+ snapshot = snapshots_dir / f"{run_dir.name}.json"
+ runs.append((run_dir, snapshot if snapshot.is_file() else None))
+ if snapshots_dir.is_dir():
+ for snapshot in sorted(snapshots_dir.glob("*.json")):
+ run_id = snapshot.stem
+ if run_id not in seen:
+ runs.append((base / run_id, snapshot))
return runs
diff --git a/dev-docs/workflows.md b/dev-docs/workflows.md
index 790cd5fd..d3015e20 100644
--- a/dev-docs/workflows.md
+++ b/dev-docs/workflows.md
@@ -3,7 +3,9 @@
> See [application_model.md](application_model.md) for the system overview.
> Issue [#174](https://github.com/daaain/claude-code-log/issues/174); landed
> as PR #191 (nested DOM), #203 (parsing), #205 (tool-input rendering),
-> #210 (tree rendering) plus visual-polish follow-ups.
+> #210 (tree rendering) plus visual-polish follow-ups, then the
+> invocation-shape-variety follow-up (scriptPath/name/args/resume shapes,
+> snapshot script recovery, failed-run surfacing).
A **dynamic workflow** is Claude Code's `Workflow` tool: the assistant
submits a JavaScript orchestrator script that fans out into many
@@ -44,6 +46,8 @@ A run under a trunk session `.jsonl` leaves:
agent-.jsonl per-agent side-channel transcript
agent-.meta.json {"agentType": "workflow-subagent"}
/workflows/.json terminal snapshot: phases + per-agent metadata
+ + the script that ran, scriptPath, args,
+ summary, error, run totals
/workflows/scripts/-.js the JS orchestrator source
```
@@ -52,13 +56,27 @@ per-agent results; `.json` appears only on completion. A running
workflow therefore parses with agents in journal order and **no phase
grouping** (`has_snapshot=False`).
+The **invocation** comes in several shapes: inline `script`, `scriptPath`
+(source in a file — the tool_use input carries NO source at all), or a
+saved-workflow `name`, optionally with `args` and `resumeFromRunId`. Only
+the inline shape embeds the orchestrator in the transcript; for the others
+the snapshot's `script` field is the recovery route (never the
+`scriptPath` file itself — it may have been edited or deleted since).
+
+A run that fails **before launching any agent** (script error on an early
+line) leaves a snapshot (`status: failed` + `error`) but no run
+dir/journal — a *snapshot-only* run.
+
## 2. Parse model ([`workflow.py`](../claude_code_log/workflow.py))
`parse_workflow_run` is **journal-led, snapshot-enriched**:
- `WorkflowRun` — `run_id`, `task_id`, `workflow_name`, `status`,
`phases`, flat `agents` (journal launch order), run `result`, token
- totals, `has_snapshot`.
+ totals, `has_snapshot`, plus the snapshot enrichment: `script` (the
+ source that ran), `script_path`, `args`, `summary` (the tool's digest
+ of the meta description), `error`, `default_model`, `duration_ms`,
+ `total_tool_calls`.
- `WorkflowPhase` — `index`, `title`, `detail`, member `agents` (the
same `WorkflowAgent` objects as the flat list).
- `WorkflowAgent` — `agent_id`, `label`, phase membership, `model`,
@@ -79,6 +97,12 @@ Two real-data quirks the parser absorbs:
(retries/abandoned included), so `len(run.agents)` can exceed
`run.agent_count`.
+Discovery (`discover_workflow_runs`) is run-dir-led but also yields
+**snapshot-only** runs: any `/workflows/.json` with no
+matching run dir (the failed-before-launch case). `parse_workflow_run`
+accepts a missing journal when the snapshot loads, producing a run with
+an empty `agents` list — so the failure stays linkable to its tool_use.
+
`load_workflow_runs(directory)` walks every session dir under a project;
`load_session_workflow_runs(.jsonl)` derives the sibling
`/subagents/workflows/` for a single-file render. Both share
@@ -112,14 +136,34 @@ them **only when runs exist**, so a no-workflow single-file render keeps
## 4. The Workflow tool_use header (snapshot-first)
`format_workflow_input` renders a meta header (name, description, phase
-pills) above the syntax-highlighted JS orchestrator.
-`resolve_workflow_header` sources it **snapshot-first**: when the linked
-run has a snapshot, `workflowName` and the snapshot phase titles win
-over the best-effort `export const meta = {...}` regex
+pills), an invocation line, then the syntax-highlighted JS orchestrator.
+
+The script shown is the **effective script**
+(`resolve_workflow_script`): the inline `script` input when present,
+else the snapshot's stored copy — so `scriptPath`/`name` invocations
+still show their orchestrator once the run completes. The invocation
+line (`workflow-invocation`) surfaces the non-inline shapes: the
+saved-workflow name, the `scriptPath` reference, and `resumeFromRunId`;
+`args` renders through the hybrid params table (HTML) / a fenced JSON
+block (Markdown), wrapped under an explicit `args` key.
+
+`resolve_workflow_header` sources the header **snapshot-first**: when
+the linked run has a snapshot, `workflowName` and the snapshot phase
+titles win over the best-effort `export const meta = {...}` regex
(`parse_workflow_meta`), which remains the fallback for a running
-workflow. The description always comes from the JS meta (the snapshot
-has no description field). When the snapshot has a name/phases but the
-JS parse missed them, a warning flags probable script-format drift.
+workflow. The meta parse runs on the effective script; the description
+comes from it (the snapshot has no description field), falling back to
+the snapshot `summary`. The meta string regexes accept any JS quote
+style (`'`/`"`/backtick) with backslash-escape support — real
+descriptions contain `\'`. A drift warning fires only when a
+**non-empty** script fails a parse the snapshot can answer; an absent
+script (the non-inline shapes pre-snapshot) is not drift.
+
+Failure surfacing: a non-`completed` terminal `status` renders as a
+chip next to the workflow name (`workflow-status-`), and the
+snapshot `error` (typically a JS stack trace) as a collapsed
+`workflow-error` fold — a snapshot-only failed run launched no agents,
+so the tree below is empty and this chrome is the only failure signal.
Each phase pill is an **anchor link** to its spliced phase card: the
splice records the phase cards' `message_index` values on
@@ -290,3 +334,8 @@ under a fold.
`test/test_workflow_rendering.py` (parse, linkage, splice, rendering,
single-file, pagination boundary) and `test/test_workflow_browser.py`
(Playwright fold).
+- Fixture: `test/test_data/workflow_scriptpath/` (generated by
+ `scripts/gen_workflow_scriptpath_fixture.py`) — the non-inline
+ invocation shapes: `wf_sp01`, a `scriptPath`+`args` run whose snapshot
+ carries the script (meta description with an escaped quote), and
+ `wf_fail01`, a snapshot-only `failed` run with no run dir.
diff --git a/scripts/gen_workflow_scriptpath_fixture.py b/scripts/gen_workflow_scriptpath_fixture.py
new file mode 100644
index 00000000..dfbfaf60
--- /dev/null
+++ b/scripts/gen_workflow_scriptpath_fixture.py
@@ -0,0 +1,361 @@
+#!/usr/bin/env python3
+"""Generate the synthesized ``workflow_scriptpath`` test fixture.
+
+Companion to ``gen_workflow_fixture.py`` (the inline-``script`` shape);
+this fixture covers the OTHER Workflow invocation shapes observed in real
+sessions (workflow-shape-variety follow-up to #174):
+
+ test/test_data/workflow_scriptpath/
+ .jsonl trunk with TWO scriptPath tool_uses
+ /
+ subagents/workflows/wf_sp01/
+ journal.jsonl normal run: 1 agent
+ agent-agsp0001.jsonl
+ agent-agsp0001.meta.json
+ workflows/
+ wf_sp01.json snapshot WITH the script that ran
+ wf_fail01.json snapshot-ONLY failed run (no run dir)
+
+Shapes exercised:
+
+- ``scriptPath`` (+ ``args``) invocation — the tool_use input carries NO
+ source; the renderer must recover the script from the snapshot's
+ ``script`` field.
+- A meta description containing a backslash-escaped quote (``team\\'s``) —
+ regression coverage for the JS-string parse.
+- ``wf_fail01``: a run that died before launching any agent (script error)
+ — a snapshot with ``status: failed`` + ``error`` but no run dir/journal.
+
+Re-run to regenerate: ``python3 scripts/gen_workflow_scriptpath_fixture.py``.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+ROOT = Path(__file__).resolve().parent.parent
+FIXTURE = ROOT / "test" / "test_data" / "workflow_scriptpath"
+
+TRUNK_SID = "22220000-0000-4000-8000-000000000002"
+TS = "2026-06-28T09:00:00.000Z"
+TS_FAIL = "2026-06-28T09:05:00.000Z"
+
+SP_SCRIPT = (
+ "export const meta = {\n"
+ " name: 'docs-sweep',\n"
+ " description: 'Sweep the team\\'s docs tree in batches',\n"
+ " phases: [\n"
+ " { title: 'Sweep', detail: 'one worker per batch' },\n"
+ " ],\n"
+ "}\n"
+ "phase('Sweep')\n"
+ "return await agent('Sweep docs: ' + JSON.stringify(args))\n"
+)
+SP_ARGS = {"target": "docs/", "batches": ["a", "b"]}
+SP_PATH = "/home/u/sweeps/docs-sweep.workflow.js"
+
+FAIL_SCRIPT = (
+ "export const meta = {\n"
+ " name: 'docs-sweep-broken',\n"
+ " description: 'Sweep variant that dies before launching agents',\n"
+ " phases: [\n"
+ " { title: 'Sweep', detail: 'never reached' },\n"
+ " ],\n"
+ "}\n"
+ "const batches = args.batches\n"
+ "await parallel(batches.map(b => () => agent('sweep ' + b)))\n"
+)
+FAIL_PATH = "/home/u/sweeps/docs-sweep-broken.workflow.js"
+
+
+def _jsonl(path: Path, rows: "list[dict[str, Any]]") -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with open(path, "w", encoding="utf-8") as fh:
+ for row in rows:
+ fh.write(json.dumps(row) + "\n")
+
+
+def _trunk() -> "list[dict[str, Any]]":
+ common = {
+ "isSidechain": False,
+ "userType": "external",
+ "cwd": "/repo",
+ "sessionId": TRUNK_SID,
+ "version": "2.1.2",
+ }
+ return [
+ {
+ "type": "user",
+ "uuid": "spu00001",
+ "parentUuid": None,
+ "timestamp": TS,
+ **common,
+ "message": {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "Run the docs sweep workflow from its file.",
+ }
+ ],
+ },
+ },
+ {
+ "type": "assistant",
+ "uuid": "spa00001",
+ "parentUuid": "spu00001",
+ "timestamp": TS,
+ **common,
+ "message": {
+ "id": "msg_spa00001",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-opus-4-8",
+ "stop_reason": "end_turn",
+ "content": [
+ {"type": "text", "text": "Launching the saved sweep script."},
+ {
+ "type": "tool_use",
+ "id": "toolu_wfsp01",
+ "name": "Workflow",
+ # scriptPath shape: NO inline script in the input
+ "input": {"scriptPath": SP_PATH, "args": SP_ARGS},
+ },
+ ],
+ "usage": {"input_tokens": 5, "output_tokens": 5},
+ },
+ },
+ {
+ "type": "user",
+ "uuid": "spu00002",
+ "parentUuid": "spa00001",
+ "timestamp": TS,
+ **common,
+ "message": {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_wfsp01",
+ "content": (
+ "Workflow launched in background. Task ID: task_sp01\n"
+ "Summary: Sweep the team's docs tree in batches.\n"
+ f"Transcript dir: {TRUNK_SID}/subagents/workflows/wf_sp01\n"
+ f"Script file: {SP_PATH}"
+ ),
+ }
+ ],
+ },
+ "toolUseResult": {
+ "isAsync": True,
+ "status": "async_launched",
+ "runId": "wf_sp01",
+ "taskId": "task_sp01",
+ "transcriptDir": f"{TRUNK_SID}/subagents/workflows/wf_sp01",
+ "scriptPath": SP_PATH,
+ },
+ },
+ {
+ "type": "assistant",
+ "uuid": "spa00002",
+ "parentUuid": "spu00002",
+ "timestamp": TS_FAIL,
+ **common,
+ "message": {
+ "id": "msg_spa00002",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-opus-4-8",
+ "stop_reason": "end_turn",
+ "content": [
+ {"type": "text", "text": "Launching the broken variant."},
+ {
+ "type": "tool_use",
+ "id": "toolu_wffail01",
+ "name": "Workflow",
+ "input": {"scriptPath": FAIL_PATH},
+ },
+ ],
+ "usage": {"input_tokens": 5, "output_tokens": 5},
+ },
+ },
+ {
+ "type": "user",
+ "uuid": "spu00003",
+ "parentUuid": "spa00002",
+ "timestamp": TS_FAIL,
+ **common,
+ "message": {
+ "role": "user",
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "toolu_wffail01",
+ "content": (
+ "Workflow launched in background. Task ID: task_fail01\n"
+ "Summary: Sweep variant that dies before launching agents.\n"
+ f"Transcript dir: {TRUNK_SID}/subagents/workflows/wf_fail01\n"
+ f"Script file: {FAIL_PATH}"
+ ),
+ }
+ ],
+ },
+ "toolUseResult": {
+ "isAsync": True,
+ "status": "async_launched",
+ "runId": "wf_fail01",
+ "taskId": "task_fail01",
+ "transcriptDir": f"{TRUNK_SID}/subagents/workflows/wf_fail01",
+ "scriptPath": FAIL_PATH,
+ },
+ },
+ ]
+
+
+def _agent_rows() -> "list[dict[str, Any]]":
+ agent_sid = f"{TRUNK_SID}#agent-agsp0001"
+ common = {
+ "isSidechain": True,
+ "userType": "external",
+ "cwd": "/repo",
+ "sessionId": agent_sid,
+ "version": "2.1.2",
+ "timestamp": TS,
+ "agentId": "agsp0001",
+ }
+ return [
+ {
+ "type": "user",
+ "uuid": "agsp0001_u1",
+ "parentUuid": None,
+ **common,
+ "message": {
+ "role": "user",
+ "content": [{"type": "text", "text": "Sweep docs batches a and b."}],
+ },
+ },
+ {
+ "type": "assistant",
+ "uuid": "agsp0001_a1",
+ "parentUuid": "agsp0001_u1",
+ **common,
+ "message": {
+ "id": "msg_agsp0001_a1",
+ "type": "message",
+ "role": "assistant",
+ "model": "claude-sonnet-4-6",
+ "stop_reason": "end_turn",
+ "content": [
+ {
+ "type": "text",
+ "text": "Batch a and b swept; 2 stale pages flagged.",
+ }
+ ],
+ "usage": {"input_tokens": 5, "output_tokens": 5},
+ },
+ },
+ ]
+
+
+def main() -> None:
+ trunk_dir = FIXTURE / TRUNK_SID
+ run_dir = trunk_dir / "subagents" / "workflows" / "wf_sp01"
+ wf_dir = trunk_dir / "workflows"
+
+ _jsonl(FIXTURE / f"{TRUNK_SID}.jsonl", _trunk())
+
+ _jsonl(
+ run_dir / "journal.jsonl",
+ [
+ {"type": "started", "key": "v2:hsp0", "agentId": "agsp0001"},
+ {
+ "type": "result",
+ "key": "v2:hsp0",
+ "agentId": "agsp0001",
+ "result": "Batch a and b swept; 2 stale pages flagged.",
+ },
+ ],
+ )
+ _jsonl(run_dir / "agent-agsp0001.jsonl", _agent_rows())
+ (run_dir / "agent-agsp0001.meta.json").write_text(
+ json.dumps({"agentType": "workflow-subagent"}, indent=2), encoding="utf-8"
+ )
+
+ wf_dir.mkdir(parents=True, exist_ok=True)
+ (wf_dir / "wf_sp01.json").write_text(
+ json.dumps(
+ {
+ "runId": "wf_sp01",
+ "taskId": "task_sp01",
+ "status": "completed",
+ "workflowName": "docs-sweep",
+ "timestamp": TS,
+ "durationMs": 2000,
+ "agentCount": 1,
+ "totalTokens": 101,
+ "totalToolCalls": 2,
+ "defaultModel": "claude-sonnet-4-6",
+ "script": SP_SCRIPT,
+ "scriptPath": SP_PATH,
+ "args": SP_ARGS,
+ "summary": "Sweep the team's docs tree in batches",
+ "phases": [{"title": "Sweep", "detail": "one worker per batch"}],
+ "workflowProgress": [
+ {"type": "workflow_phase", "index": 1, "title": "Sweep"},
+ {
+ "type": "workflow_agent",
+ "index": 0,
+ "label": "sweep:a",
+ "phaseIndex": 1,
+ "phaseTitle": "Sweep",
+ "agentId": "agsp0001",
+ "model": "claude-sonnet-4-6",
+ "state": "done",
+ "attempt": 1,
+ "tokens": 101,
+ "toolCalls": 2,
+ "durationMs": 900,
+ "resultPreview": "Batch a and b swept",
+ },
+ ],
+ },
+ indent=2,
+ ),
+ encoding="utf-8",
+ )
+
+ # Snapshot-ONLY failed run: deliberately no subagents/workflows/wf_fail01 dir.
+ (wf_dir / "wf_fail01.json").write_text(
+ json.dumps(
+ {
+ "runId": "wf_fail01",
+ "taskId": "task_fail01",
+ "status": "failed",
+ "workflowName": "docs-sweep-broken",
+ "timestamp": TS_FAIL,
+ "durationMs": 12,
+ "agentCount": 0,
+ "totalTokens": 0,
+ "totalToolCalls": 0,
+ "defaultModel": "claude-sonnet-4-6",
+ "script": FAIL_SCRIPT,
+ "scriptPath": FAIL_PATH,
+ "summary": "Sweep variant that dies before launching agents",
+ "error": (
+ "Error: undefined is not an object (evaluating 'batches.map')\n"
+ " at (workflow.js:9:10)"
+ ),
+ "phases": [{"title": "Sweep", "detail": "never reached"}],
+ "workflowProgress": [],
+ },
+ indent=2,
+ ),
+ encoding="utf-8",
+ )
+ print(f"workflow_scriptpath fixture written under {FIXTURE}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test/__snapshots__/test_snapshot_html.ambr b/test/__snapshots__/test_snapshot_html.ambr
index 3f2b4665..4002beb8 100644
--- a/test/__snapshots__/test_snapshot_html.ambr
+++ b/test/__snapshots__/test_snapshot_html.ambr
@@ -1872,6 +1872,48 @@
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;
}
@@ -8312,6 +8354,48 @@
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;
}
@@ -16854,6 +16938,48 @@
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;
}
@@ -23409,6 +23535,48 @@
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;
}
@@ -30231,6 +30399,48 @@
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;
}
@@ -37016,6 +37226,48 @@
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;
}
@@ -43744,6 +43996,48 @@
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;
}
diff --git a/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002.jsonl b/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002.jsonl
new file mode 100644
index 00000000..d237526a
--- /dev/null
+++ b/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002.jsonl
@@ -0,0 +1,5 @@
+{"type": "user", "uuid": "spu00001", "parentUuid": null, "timestamp": "2026-06-28T09:00:00.000Z", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "22220000-0000-4000-8000-000000000002", "version": "2.1.2", "message": {"role": "user", "content": [{"type": "text", "text": "Run the docs sweep workflow from its file."}]}}
+{"type": "assistant", "uuid": "spa00001", "parentUuid": "spu00001", "timestamp": "2026-06-28T09:00:00.000Z", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "22220000-0000-4000-8000-000000000002", "version": "2.1.2", "message": {"id": "msg_spa00001", "type": "message", "role": "assistant", "model": "claude-opus-4-8", "stop_reason": "end_turn", "content": [{"type": "text", "text": "Launching the saved sweep script."}, {"type": "tool_use", "id": "toolu_wfsp01", "name": "Workflow", "input": {"scriptPath": "/home/u/sweeps/docs-sweep.workflow.js", "args": {"target": "docs/", "batches": ["a", "b"]}}}], "usage": {"input_tokens": 5, "output_tokens": 5}}}
+{"type": "user", "uuid": "spu00002", "parentUuid": "spa00001", "timestamp": "2026-06-28T09:00:00.000Z", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "22220000-0000-4000-8000-000000000002", "version": "2.1.2", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_wfsp01", "content": "Workflow launched in background. Task ID: task_sp01\nSummary: Sweep the team's docs tree in batches.\nTranscript dir: 22220000-0000-4000-8000-000000000002/subagents/workflows/wf_sp01\nScript file: /home/u/sweeps/docs-sweep.workflow.js"}]}, "toolUseResult": {"isAsync": true, "status": "async_launched", "runId": "wf_sp01", "taskId": "task_sp01", "transcriptDir": "22220000-0000-4000-8000-000000000002/subagents/workflows/wf_sp01", "scriptPath": "/home/u/sweeps/docs-sweep.workflow.js"}}
+{"type": "assistant", "uuid": "spa00002", "parentUuid": "spu00002", "timestamp": "2026-06-28T09:05:00.000Z", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "22220000-0000-4000-8000-000000000002", "version": "2.1.2", "message": {"id": "msg_spa00002", "type": "message", "role": "assistant", "model": "claude-opus-4-8", "stop_reason": "end_turn", "content": [{"type": "text", "text": "Launching the broken variant."}, {"type": "tool_use", "id": "toolu_wffail01", "name": "Workflow", "input": {"scriptPath": "/home/u/sweeps/docs-sweep-broken.workflow.js"}}], "usage": {"input_tokens": 5, "output_tokens": 5}}}
+{"type": "user", "uuid": "spu00003", "parentUuid": "spa00002", "timestamp": "2026-06-28T09:05:00.000Z", "isSidechain": false, "userType": "external", "cwd": "/repo", "sessionId": "22220000-0000-4000-8000-000000000002", "version": "2.1.2", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_wffail01", "content": "Workflow launched in background. Task ID: task_fail01\nSummary: Sweep variant that dies before launching agents.\nTranscript dir: 22220000-0000-4000-8000-000000000002/subagents/workflows/wf_fail01\nScript file: /home/u/sweeps/docs-sweep-broken.workflow.js"}]}, "toolUseResult": {"isAsync": true, "status": "async_launched", "runId": "wf_fail01", "taskId": "task_fail01", "transcriptDir": "22220000-0000-4000-8000-000000000002/subagents/workflows/wf_fail01", "scriptPath": "/home/u/sweeps/docs-sweep-broken.workflow.js"}}
diff --git a/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/subagents/workflows/wf_sp01/agent-agsp0001.jsonl b/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/subagents/workflows/wf_sp01/agent-agsp0001.jsonl
new file mode 100644
index 00000000..c653c6cf
--- /dev/null
+++ b/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/subagents/workflows/wf_sp01/agent-agsp0001.jsonl
@@ -0,0 +1,2 @@
+{"type": "user", "uuid": "agsp0001_u1", "parentUuid": null, "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "22220000-0000-4000-8000-000000000002#agent-agsp0001", "version": "2.1.2", "timestamp": "2026-06-28T09:00:00.000Z", "agentId": "agsp0001", "message": {"role": "user", "content": [{"type": "text", "text": "Sweep docs batches a and b."}]}}
+{"type": "assistant", "uuid": "agsp0001_a1", "parentUuid": "agsp0001_u1", "isSidechain": true, "userType": "external", "cwd": "/repo", "sessionId": "22220000-0000-4000-8000-000000000002#agent-agsp0001", "version": "2.1.2", "timestamp": "2026-06-28T09:00:00.000Z", "agentId": "agsp0001", "message": {"id": "msg_agsp0001_a1", "type": "message", "role": "assistant", "model": "claude-sonnet-4-6", "stop_reason": "end_turn", "content": [{"type": "text", "text": "Batch a and b swept; 2 stale pages flagged."}], "usage": {"input_tokens": 5, "output_tokens": 5}}}
diff --git a/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/subagents/workflows/wf_sp01/agent-agsp0001.meta.json b/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/subagents/workflows/wf_sp01/agent-agsp0001.meta.json
new file mode 100644
index 00000000..cd994ca9
--- /dev/null
+++ b/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/subagents/workflows/wf_sp01/agent-agsp0001.meta.json
@@ -0,0 +1,3 @@
+{
+ "agentType": "workflow-subagent"
+}
\ No newline at end of file
diff --git a/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/subagents/workflows/wf_sp01/journal.jsonl b/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/subagents/workflows/wf_sp01/journal.jsonl
new file mode 100644
index 00000000..e03beb26
--- /dev/null
+++ b/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/subagents/workflows/wf_sp01/journal.jsonl
@@ -0,0 +1,2 @@
+{"type": "started", "key": "v2:hsp0", "agentId": "agsp0001"}
+{"type": "result", "key": "v2:hsp0", "agentId": "agsp0001", "result": "Batch a and b swept; 2 stale pages flagged."}
diff --git a/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/workflows/wf_fail01.json b/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/workflows/wf_fail01.json
new file mode 100644
index 00000000..f808e8a8
--- /dev/null
+++ b/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/workflows/wf_fail01.json
@@ -0,0 +1,23 @@
+{
+ "runId": "wf_fail01",
+ "taskId": "task_fail01",
+ "status": "failed",
+ "workflowName": "docs-sweep-broken",
+ "timestamp": "2026-06-28T09:05:00.000Z",
+ "durationMs": 12,
+ "agentCount": 0,
+ "totalTokens": 0,
+ "totalToolCalls": 0,
+ "defaultModel": "claude-sonnet-4-6",
+ "script": "export const meta = {\n name: 'docs-sweep-broken',\n description: 'Sweep variant that dies before launching agents',\n phases: [\n { title: 'Sweep', detail: 'never reached' },\n ],\n}\nconst batches = args.batches\nawait parallel(batches.map(b => () => agent('sweep ' + b)))\n",
+ "scriptPath": "/home/u/sweeps/docs-sweep-broken.workflow.js",
+ "summary": "Sweep variant that dies before launching agents",
+ "error": "Error: undefined is not an object (evaluating 'batches.map')\n at (workflow.js:9:10)",
+ "phases": [
+ {
+ "title": "Sweep",
+ "detail": "never reached"
+ }
+ ],
+ "workflowProgress": []
+}
\ No newline at end of file
diff --git a/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/workflows/wf_sp01.json b/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/workflows/wf_sp01.json
new file mode 100644
index 00000000..e5a04132
--- /dev/null
+++ b/test/test_data/workflow_scriptpath/22220000-0000-4000-8000-000000000002/workflows/wf_sp01.json
@@ -0,0 +1,50 @@
+{
+ "runId": "wf_sp01",
+ "taskId": "task_sp01",
+ "status": "completed",
+ "workflowName": "docs-sweep",
+ "timestamp": "2026-06-28T09:00:00.000Z",
+ "durationMs": 2000,
+ "agentCount": 1,
+ "totalTokens": 101,
+ "totalToolCalls": 2,
+ "defaultModel": "claude-sonnet-4-6",
+ "script": "export const meta = {\n name: 'docs-sweep',\n description: 'Sweep the team\\'s docs tree in batches',\n phases: [\n { title: 'Sweep', detail: 'one worker per batch' },\n ],\n}\nphase('Sweep')\nreturn await agent('Sweep docs: ' + JSON.stringify(args))\n",
+ "scriptPath": "/home/u/sweeps/docs-sweep.workflow.js",
+ "args": {
+ "target": "docs/",
+ "batches": [
+ "a",
+ "b"
+ ]
+ },
+ "summary": "Sweep the team's docs tree in batches",
+ "phases": [
+ {
+ "title": "Sweep",
+ "detail": "one worker per batch"
+ }
+ ],
+ "workflowProgress": [
+ {
+ "type": "workflow_phase",
+ "index": 1,
+ "title": "Sweep"
+ },
+ {
+ "type": "workflow_agent",
+ "index": 0,
+ "label": "sweep:a",
+ "phaseIndex": 1,
+ "phaseTitle": "Sweep",
+ "agentId": "agsp0001",
+ "model": "claude-sonnet-4-6",
+ "state": "done",
+ "attempt": 1,
+ "tokens": 101,
+ "toolCalls": 2,
+ "durationMs": 900,
+ "resultPreview": "Batch a and b swept"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/test/test_workflow_rendering.py b/test/test_workflow_rendering.py
index a82f794e..7409a020 100644
--- a/test/test_workflow_rendering.py
+++ b/test/test_workflow_rendering.py
@@ -134,6 +134,35 @@ 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") == ("", "", [])
+ def test_escaped_quote_in_string_does_not_truncate(self) -> None:
+ # Observed in real meta blocks ('SAM\'s ...'): the old [^'\"]* pattern
+ # cut the description at the escaped quote.
+ script = (
+ "export const meta = {\n"
+ " name: 'sweep',\n"
+ " description: 'Stress-test SAM\\'s operative layer',\n"
+ " phases: [{ title: 'The \\'inner\\' pass' }],\n"
+ "}\n"
+ )
+ name, desc, phases = parse_workflow_meta(script)
+ assert name == "sweep"
+ assert desc == "Stress-test SAM's operative layer"
+ assert phases == ["The 'inner' pass"]
+
+ def test_backtick_and_double_quote_strings(self) -> None:
+ script = (
+ "export const meta = {\n"
+ " name: `tick-name`,\n"
+ ' description: "double quoted",\n'
+ " phases: [{ title: `Scan` }],\n"
+ "}\n"
+ )
+ assert parse_workflow_meta(script) == (
+ "tick-name",
+ "double quoted",
+ ["Scan"],
+ )
+
_JS_META = (
"export const meta = {\n"
@@ -193,6 +222,162 @@ def test_drift_warning_when_js_meta_misses(self, caplog) -> None:
assert any("may have drifted" in r.message for r in caplog.records)
+class TestSnapshotScriptFallback:
+ """Shape variety (#174 follow-up): the tool_use input carries a script only
+ in the inline-``script`` invocation shape. For ``scriptPath`` / ``name`` /
+ ``resumeFromRunId`` shapes the header must fall back to the snapshot's
+ stored ``script`` — and an absent script is NOT meta-format drift, so the
+ warning must stay quiet."""
+
+ 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=True,
+ phases=kw.get("phases", [WorkflowPhase(index=0, title="Alpha")]),
+ script=kw.get("script", ""),
+ summary=kw.get("summary", ""),
+ )
+
+ def test_resolve_workflow_script_prefers_input_then_snapshot(self) -> None:
+ from claude_code_log.workflow import resolve_workflow_script
+
+ run = self._run(script="// from snapshot")
+ assert resolve_workflow_script(run, "// inline") == "// inline"
+ assert resolve_workflow_script(run, "") == "// from snapshot"
+ assert resolve_workflow_script(None, "") == ""
+
+ def test_empty_input_script_parses_snapshot_script_no_warning(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"):
+ name, desc, phases = resolve_workflow_header(self._run(script=_JS_META), "")
+ # description recovered from the snapshot-stored script's meta block
+ assert desc == "js-desc"
+ assert name == "SNAP-NAME" # snapshot-first name still wins
+ assert phases == ["Alpha"]
+ assert not caplog.records # a parseable snapshot script → nothing drifted
+
+ def test_no_script_anywhere_is_not_drift(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"):
+ name, desc, phases = resolve_workflow_header(self._run(), "")
+ assert name == "SNAP-NAME" and phases == ["Alpha"]
+ assert not caplog.records # no source text at all → no drift warning
+
+ def test_description_falls_back_to_snapshot_summary(self) -> None:
+ from claude_code_log.workflow import resolve_workflow_header
+
+ _, desc, _ = resolve_workflow_header(
+ self._run(summary="digest of the description"), ""
+ )
+ assert desc == "digest of the description"
+
+
+class TestWorkflowToolInputShapes:
+ """WorkflowToolInput declares the full invocation-shape union — scriptPath /
+ name / args / resumeFromRunId no longer vanish into `extra=\"allow\"`."""
+
+ def test_scriptpath_shape_with_args(self) -> None:
+ from claude_code_log.models import WorkflowToolInput
+
+ inp = WorkflowToolInput.model_validate(
+ {
+ "scriptPath": "/home/u/sweep.workflow.js",
+ "args": {"batches": [1, 2]},
+ }
+ )
+ assert inp.script == ""
+ assert inp.script_path == "/home/u/sweep.workflow.js"
+ assert inp.args == {"batches": [1, 2]}
+
+ def test_saved_name_and_resume_shape(self) -> None:
+ from claude_code_log.models import WorkflowToolInput
+
+ inp = WorkflowToolInput.model_validate(
+ {"name": "review-changes", "resumeFromRunId": "wf_abc123-def"}
+ )
+ assert inp.name == "review-changes"
+ assert inp.resume_from_run_id == "wf_abc123-def"
+
+
+class TestSnapshotEnrichmentParsing:
+ """The .json snapshot carries far more than phases/agent metadata —
+ script, scriptPath, args, summary, error, defaultModel, durationMs,
+ totalToolCalls all surface on WorkflowRun."""
+
+ def test_enrichment_fields_parsed(self, tmp_path: Path) -> None:
+ import json
+
+ from claude_code_log.workflow import parse_workflow_run
+
+ run_dir = tmp_path / "subagents" / "workflows" / "wf_enrich1"
+ run_dir.mkdir(parents=True)
+ (run_dir / "journal.jsonl").write_text(
+ '{"type": "result", "agentId": "ag1", "result": "ok"}\n',
+ encoding="utf-8",
+ )
+ snapshot = tmp_path / "wf_enrich1.json"
+ snapshot.write_text(
+ json.dumps(
+ {
+ "runId": "wf_enrich1",
+ "taskId": "task_e1",
+ "workflowName": "enriched",
+ "status": "completed",
+ "script": "export const meta = {\n}\n",
+ "scriptPath": "/somewhere/enriched.workflow.js",
+ "args": {"target": "docs/"},
+ "summary": "the digest",
+ "defaultModel": "claude-sonnet-4-6",
+ "durationMs": 1234,
+ "totalToolCalls": 7,
+ "phases": [],
+ }
+ ),
+ encoding="utf-8",
+ )
+ run = parse_workflow_run(run_dir, snapshot)
+ assert run is not None
+ assert run.script.startswith("export const meta")
+ assert run.script_path == "/somewhere/enriched.workflow.js"
+ assert run.args == {"target": "docs/"}
+ assert run.summary == "the digest"
+ assert run.error == ""
+ assert run.default_model == "claude-sonnet-4-6"
+ assert run.duration_ms == 1234
+ assert run.total_tool_calls == 7
+
+ def test_non_string_enrichment_values_coerced_to_empty(
+ self, tmp_path: Path
+ ) -> None:
+ import json
+
+ from claude_code_log.workflow import parse_workflow_run
+
+ run_dir = tmp_path / "subagents" / "workflows" / "wf_enrich2"
+ run_dir.mkdir(parents=True)
+ (run_dir / "journal.jsonl").write_text(
+ '{"type": "result", "agentId": "ag1", "result": "ok"}\n',
+ encoding="utf-8",
+ )
+ snapshot = tmp_path / "wf_enrich2.json"
+ snapshot.write_text(
+ json.dumps({"runId": "wf_enrich2", "script": 42, "error": ["x"]}),
+ encoding="utf-8",
+ )
+ run = parse_workflow_run(run_dir, snapshot)
+ assert run is not None
+ assert run.script == "" and run.error == ""
+
+
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."""
@@ -439,6 +624,190 @@ def test_non_workflow_transcript_has_no_spliced_nodes(self) -> None:
assert "workflow_agent" not in types
+SCRIPTPATH_TRUNK = (
+ Path(__file__).parent
+ / "test_data"
+ / "workflow_scriptpath"
+ / "22220000-0000-4000-8000-000000000002.jsonl"
+)
+
+
+class TestScriptPathInvocationRendering:
+ """Shape variety (#174 follow-up): a `scriptPath` + `args` invocation
+ carries no inline source — the renderer recovers the script from the
+ run's terminal snapshot and surfaces the invocation itself (script file
+ reference + args)."""
+
+ def _html(self) -> str:
+ from claude_code_log.converter import load_directory_transcripts
+
+ msgs, tree = load_directory_transcripts(SCRIPTPATH_TRUNK.parent, silent=True)
+ return generate_html(msgs, session_tree=tree)
+
+ def _md(self) -> str:
+ from claude_code_log.converter import load_directory_transcripts
+
+ msgs, tree = load_directory_transcripts(SCRIPTPATH_TRUNK.parent, silent=True)
+ return MarkdownRenderer().generate(msgs, session_tree=tree)
+
+ def test_header_fully_populated_from_snapshot_script(self) -> None:
+ html = self._html()
+ assert "workflow-name" in html and "docs-sweep" in html
+ # description comes from the snapshot-stored script's meta block —
+ # the JS escape (\') is unescaped, then HTML-escaped for display
+ assert "Sweep the team's docs tree in batches" in html
+ assert "workflow-phase-pill" in html and "Sweep" in html
+
+ def test_snapshot_script_body_rendered(self) -> None:
+ html = self._html()
+ idx = html.find("class='workflow-script'")
+ assert idx != -1, "snapshot-recovered script should render as the body"
+ assert "highlight" in html[idx : idx + 600]
+
+ def test_invocation_line_shows_script_path(self) -> None:
+ html = self._html()
+ idx = html.find("class='workflow-invocation'")
+ assert idx != -1
+ assert "/home/u/sweeps/docs-sweep.workflow.js" in html[idx : idx + 400]
+
+ def test_args_rendered_as_params_table(self) -> None:
+ html = self._html()
+ # args render via the hybrid params table, wrapped under an "args" key.
+ # Target the rendered div, not the `.workflow-invocation` CSS rule.
+ tool_use_at = html.find("class='workflow-invocation'")
+ assert tool_use_at != -1
+ segment = html[tool_use_at : tool_use_at + 4000]
+ assert "tool-params-table" in segment
+ assert "args" in segment and "docs/" in segment
+
+ def test_run_tree_still_spliced(self) -> None:
+ html = self._html()
+ assert "Phase: Sweep" in html
+ assert "sweep:a" in html
+ assert "stale pages flagged" in html # agent result surfaced
+
+ def test_markdown_parity(self) -> None:
+ md = self._md()
+ assert "docs-sweep" in md
+ assert "Sweep the team's docs tree in batches" in md
+ assert "script: `/home/u/sweeps/docs-sweep.workflow.js`" in md
+ assert "**args:**" in md and '"target": "docs/"' in md
+ assert "```js" in md and "export const meta" in md # snapshot script
+
+ def test_no_drift_warnings_emitted(self, caplog) -> None:
+ import logging
+
+ with caplog.at_level(logging.WARNING, logger="claude_code_log.workflow"):
+ self._html()
+ assert not any("may have drifted" in r.message for r in caplog.records)
+
+
+class TestSnapshotOnlyFailedRun:
+ """A workflow that dies before launching any agent (script error on an
+ early line) leaves a .json snapshot but NO run dir/journal.
+ Journal-led discovery used to skip it entirely — the tool_use rendered
+ bare, with the status and error invisible."""
+
+ def _html(self) -> str:
+ from claude_code_log.converter import load_directory_transcripts
+
+ msgs, tree = load_directory_transcripts(SCRIPTPATH_TRUNK.parent, silent=True)
+ return generate_html(msgs, session_tree=tree)
+
+ def test_snapshot_only_run_discovered(self) -> None:
+ from claude_code_log.workflow import load_session_workflow_runs
+
+ runs = load_session_workflow_runs(SCRIPTPATH_TRUNK, silent=True)
+ by_id = {r.run_id: r for r in runs}
+ assert set(by_id) == {"wf_sp01", "wf_fail01"}
+ failed = by_id["wf_fail01"]
+ assert failed.has_snapshot
+ assert failed.status == "failed"
+ assert failed.agents == []
+ assert "batches.map" in failed.error
+
+ def test_missing_journal_and_snapshot_still_returns_none(
+ self, tmp_path: Path
+ ) -> None:
+ from claude_code_log.workflow import parse_workflow_run
+
+ assert parse_workflow_run(tmp_path / "wf_nope") is None
+ # unparseable snapshot alone doesn't fabricate a run either
+ bad = tmp_path / "wf_bad.json"
+ bad.write_text("not json", encoding="utf-8")
+ assert parse_workflow_run(tmp_path / "wf_bad", bad) is None
+
+ def test_failed_status_and_error_rendered_html(self) -> None:
+ html = self._html()
+ # status chip next to the workflow name
+ i = html.find("class='workflow-status workflow-status-failed'")
+ assert i != -1
+ assert ">failed<" in html[i : i + 120]
+ assert "docs-sweep-broken" in html
+ # error fold with the stack trace, HTML-escaped
+ j = html.find("class='workflow-error'")
+ assert j != -1
+ assert "batches.map" in html[j : j + 600]
+ # the snapshot-stored script still renders for the failed run
+ assert "never reached" in html
+
+ def test_completed_run_shows_no_status_chip(self) -> None:
+ html = self._html()
+ # exactly one failed chip (wf_fail01); wf_sp01 is completed → none
+ assert html.count("workflow-status-failed") == 1
+ assert "workflow-status-completed" not in html
+
+ def test_failed_status_and_error_rendered_markdown(self) -> None:
+ from claude_code_log.converter import load_directory_transcripts
+
+ msgs, tree = load_directory_transcripts(SCRIPTPATH_TRUNK.parent, silent=True)
+ md = MarkdownRenderer().generate(msgs, session_tree=tree)
+ assert "`failed`" in md
+ assert "**error:**" in md
+ assert "batches.map" in md
+
+
+class TestInvocationChromeUnit:
+ """Direct formatter coverage for the saved-name / resume shapes (no
+ fixture needed — these carry no run at all)."""
+
+ def test_saved_name_and_resume_html(self) -> None:
+ from claude_code_log.html.tool_formatters import format_workflow_input
+ from claude_code_log.models import WorkflowToolInput
+
+ out = format_workflow_input(
+ WorkflowToolInput.model_validate(
+ {"name": "review-changes", "resumeFromRunId": "wf_prev01-abc"}
+ )
+ )
+ assert "saved workflow:" in out and "review-changes" in out
+ assert "resumes:" in out and "wf_prev01-abc" in out
+
+ def test_saved_name_and_resume_markdown(self) -> None:
+ from claude_code_log.models import WorkflowToolInput
+
+ out = MarkdownRenderer().format_WorkflowToolInput(
+ WorkflowToolInput.model_validate(
+ {"name": "review-changes", "resumeFromRunId": "wf_prev01-abc"}
+ ),
+ None, # type: ignore[arg-type] # message unused by this formatter
+ )
+ assert "saved workflow: `review-changes`" in out
+ assert "resumes: `wf_prev01-abc`" in out
+
+ def test_html_chrome_escapes_html(self) -> None:
+ from claude_code_log.html.tool_formatters import format_workflow_input
+ from claude_code_log.models import WorkflowToolInput
+
+ out = format_workflow_input(
+ WorkflowToolInput.model_validate(
+ {"scriptPath": "/tmp/.js"}
+ )
+ )
+ assert "