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 "