From ea729cf17f2b0879932173121caf7295c6e0e3f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 15:30:55 +0000 Subject: [PATCH] feat(studio): nest per-iteration / per-region step logs in the flow Runs panel (#1505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlowRunsPanel rendered a run's step log flat, so a `loop` container showed as a single step and its body steps — one set per iteration — appeared as an undifferentiated repeat of the same node ids, with `parallel` branches and `try`/`catch` handlers likewise flattened. The automation engine already tags each structured-region body step with its container (`parentNodeId`) plus an `iteration` / `regionKind` (ADR-0031, framework #1505); the panel ignored them. Reconstruct the execution tree from the flat, pre-order step log (`buildStepTree`) and nest body steps under their container node, grouped by a per-iteration / per-branch / handler header (`Iteration 2`, `Branch 1`, `Try`, `Catch`). The reconstruction is robust to repeated node ids (a loop body node runs once per iteration) and to regions nested inside regions, and degrades safely — a body step whose container was dropped by durable-history truncation still surfaces at the top level rather than vanishing. Adds `buildStepTree` / `regionLabel` unit tests and a render test asserting the loop per-iteration headers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VuvxWgoadqryqBcjs7TpVi --- .changeset/flow-runs-region-step-logs.md | 21 +++ .../previews/FlowRunsPanel.render.test.tsx | 33 ++++ .../previews/FlowRunsPanel.test.ts | 91 +++++++++- .../metadata-admin/previews/FlowRunsPanel.tsx | 156 +++++++++++++++++- 4 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 .changeset/flow-runs-region-step-logs.md diff --git a/.changeset/flow-runs-region-step-logs.md b/.changeset/flow-runs-region-step-logs.md new file mode 100644 index 000000000..d2bdb4384 --- /dev/null +++ b/.changeset/flow-runs-region-step-logs.md @@ -0,0 +1,21 @@ +--- +"@object-ui/app-shell": minor +--- + +feat(studio): nest per-iteration / per-region step logs in the flow Runs panel (#1505) + +The run-observability `FlowRunsPanel` (Studio → flow preview → Runs) rendered a +run's step log as a flat list, so a `loop` container showed as a single step and +its body steps — one set per iteration — appeared as an undifferentiated repeat +of the same node ids, with `parallel` branches and `try`/`catch` handlers +likewise flattened. The automation engine already tags each structured-region +body step with its container (`parentNodeId`) plus an `iteration` / `regionKind` +(ADR-0031, framework #1505); the panel ignored those fields. + +`FlowRunsPanel` now reconstructs the execution tree from the flat, pre-order step +log (`buildStepTree`) and nests body steps under their container node, grouped by +a per-iteration / per-branch / handler header (`Iteration 2`, `Branch 1`, `Try`, +`Catch`). The reconstruction is robust to repeated node ids (a loop body node +runs once per iteration) and to regions nested inside regions, and degrades +safely — a body step whose container was dropped by durable-history truncation +still surfaces at the top level rather than vanishing. diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.render.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.render.test.tsx index b64fc7799..06e87ef42 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.render.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.render.test.tsx @@ -44,4 +44,37 @@ describe('FlowRunsPanel (render)', () => { // The string reason must now be in the DOM (pre-fix it was silently dropped). expect(await screen.findByText(/catch region failed/)).toBeTruthy(); }); + + // #1505: a loop's body steps used to render as a flat, indistinguishable + // repeat of the same node ids. They must now nest under per-iteration headers. + it('nests loop body steps under per-iteration headers', async () => { + const LOOP_RUN = { + id: 'run_loop_01', + status: 'completed', + startedAt: '2026-07-04T13:51:13.000Z', + durationMs: 30, + trigger: { type: 'manual' }, + steps: [ + { nodeId: 'start', nodeType: 'start', status: 'success' }, + { nodeId: 'each_order', nodeType: 'loop', status: 'success' }, + { nodeId: 'charge', nodeType: 'http', status: 'success', parentNodeId: 'each_order', iteration: 0, regionKind: 'loop-body' }, + { nodeId: 'charge', nodeType: 'http', status: 'success', parentNodeId: 'each_order', iteration: 1, regionKind: 'loop-body' }, + ], + }; + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ success: true, data: { runs: [LOOP_RUN] } }), { status: 200 })), + ); + + render(); + fireEvent.click(await screen.findByRole('button', { expanded: false })); + + // Each iteration gets its own header, so the two runs of the body are + // distinguishable rather than a flat repeat of `charge`. + expect(await screen.findByText('Iteration 1')).toBeTruthy(); + expect(screen.getByText('Iteration 2')).toBeTruthy(); + // The loop container renders once; its body step renders once per iteration. + expect(screen.getByText('each_order')).toBeTruthy(); + expect(screen.getAllByText('charge')).toHaveLength(2); + }); }); diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.test.ts b/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.test.ts index 8610a45ae..a070384b6 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.test.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.test.ts @@ -1,7 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, vi, afterEach } from 'vitest'; -import { fetchFlowRuns, errorText } from './FlowRunsPanel'; +import { fetchFlowRuns, errorText, buildStepTree, regionLabel } from './FlowRunsPanel'; +import type { StepTreeNode } from './FlowRunsPanel'; const RUN = { id: 'run-1', @@ -75,3 +76,91 @@ describe('errorText', () => { expect(errorText({ code: 'E' })).toBeUndefined(); }); }); + +// ── #1505: structured-region (loop / parallel / try-catch) step grouping ── + +/** Compact a step tree to `nodeId(child,child…)` strings for legible asserts. */ +function outline(nodes: StepTreeNode[]): string[] { + return nodes.map((n) => + n.children.length ? `${n.step.nodeId}(${outline(n.children).join(',')})` : n.step.nodeId, + ); +} + +describe('buildStepTree', () => { + it('keeps a flat top-level log flat (no regions)', () => { + const tree = buildStepTree([ + { nodeId: 'start', status: 'success' }, + { nodeId: 'notify', status: 'success' }, + ]); + expect(outline(tree)).toEqual(['start', 'notify']); + }); + + it("nests a loop's body steps (across iterations) under the loop node", () => { + const tree = buildStepTree([ + { nodeId: 'start', status: 'success' }, + { nodeId: 'loop1', nodeType: 'loop', status: 'success' }, + { nodeId: 'send', status: 'success', parentNodeId: 'loop1', iteration: 0, regionKind: 'loop-body' }, + { nodeId: 'log', status: 'success', parentNodeId: 'loop1', iteration: 0, regionKind: 'loop-body' }, + { nodeId: 'send', status: 'success', parentNodeId: 'loop1', iteration: 1, regionKind: 'loop-body' }, + { nodeId: 'log', status: 'success', parentNodeId: 'loop1', iteration: 1, regionKind: 'loop-body' }, + ]); + expect(outline(tree)).toEqual(['start', 'loop1(send,log,send,log)']); + // The per-iteration index is preserved on each child (drives the header split). + expect(tree[1].children.map((c) => c.step.iteration)).toEqual([0, 0, 1, 1]); + }); + + it('nests parallel branch steps under the parallel node', () => { + const tree = buildStepTree([ + { nodeId: 'par', nodeType: 'parallel', status: 'success' }, + { nodeId: 'a', status: 'success', parentNodeId: 'par', iteration: 0, regionKind: 'parallel-branch' }, + { nodeId: 'b', status: 'success', parentNodeId: 'par', iteration: 1, regionKind: 'parallel-branch' }, + ]); + expect(outline(tree)).toEqual(['par(a,b)']); + }); + + it('nests try and catch handler steps under the try_catch node', () => { + const tree = buildStepTree([ + { nodeId: 'tc', nodeType: 'try_catch', status: 'success' }, + { nodeId: 'risky', status: 'failure', parentNodeId: 'tc', regionKind: 'try' }, + { nodeId: 'recover', status: 'success', parentNodeId: 'tc', regionKind: 'catch' }, + ]); + expect(outline(tree)).toEqual(['tc(risky,recover)']); + }); + + it('reconstructs nested regions (a loop inside a loop)', () => { + const tree = buildStepTree([ + { nodeId: 'outer', nodeType: 'loop', status: 'success' }, + { nodeId: 'inner', nodeType: 'loop', status: 'success', parentNodeId: 'outer', iteration: 0, regionKind: 'loop-body' }, + { nodeId: 'body', status: 'success', parentNodeId: 'inner', iteration: 0, regionKind: 'loop-body' }, + { nodeId: 'inner', nodeType: 'loop', status: 'success', parentNodeId: 'outer', iteration: 1, regionKind: 'loop-body' }, + { nodeId: 'body', status: 'success', parentNodeId: 'inner', iteration: 0, regionKind: 'loop-body' }, + ]); + expect(outline(tree)).toEqual(['outer(inner(body),inner(body))']); + }); + + it('surfaces an orphaned body step (truncated history) at the top level', () => { + // The loop container step was dropped (e.g. by durable-history compaction); + // its body step must still show rather than vanish. + const tree = buildStepTree([ + { nodeId: 'body', status: 'success', parentNodeId: 'gone', iteration: 3, regionKind: 'loop-body' }, + ]); + expect(outline(tree)).toEqual(['body']); + }); +}); + +describe('regionLabel', () => { + it('labels loop iterations 1-based', () => { + expect(regionLabel({ nodeId: 'x', status: 'success', regionKind: 'loop-body', iteration: 0 })).toBe('Iteration 1'); + expect(regionLabel({ nodeId: 'x', status: 'success', regionKind: 'loop-body', iteration: 4 })).toBe('Iteration 5'); + }); + it('labels parallel branches 1-based', () => { + expect(regionLabel({ nodeId: 'x', status: 'success', regionKind: 'parallel-branch', iteration: 1 })).toBe('Branch 2'); + }); + it('labels try / catch handlers', () => { + expect(regionLabel({ nodeId: 'x', status: 'success', regionKind: 'try' })).toBe('Try'); + expect(regionLabel({ nodeId: 'x', status: 'success', regionKind: 'catch' })).toBe('Catch'); + }); + it('is null for a top-level step (no region)', () => { + expect(regionLabel({ nodeId: 'x', status: 'success' })).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.tsx b/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.tsx index 5cc203674..969eb6042 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.tsx +++ b/packages/app-shell/src/views/metadata-admin/previews/FlowRunsPanel.tsx @@ -4,8 +4,12 @@ * FlowRunsPanel — run history for a flow, fetched from the automation engine * (`GET /api/v1/automation/{name}/runs`, the observability surface next to * resume/screen). Renders each run's status / start time / duration with an - * expandable per-node step log (the `ExecutionLog.steps` ADR-0019/#1479 shape), - * so authors can see where a run paused or failed without leaving the Studio. + * expandable step log (the `ExecutionLog.steps` ADR-0019/#1479 shape). Body + * steps that ran inside a structured control-flow region — a `loop` iteration, + * a `parallel` branch, or a `try`/`catch` handler — are nested under their + * container node and grouped by iteration / branch (#1505), so authors can see + * where a run paused or failed *and which iteration did it*, without leaving the + * Studio. * * Degrades like the palette fetch: offline / plugin-absent / older backend → * a quiet "history unavailable" note, never an error state that blocks the @@ -29,6 +33,23 @@ interface RunStep { status: 'success' | 'failure' | 'skipped' | string; durationMs?: number; error?: RunError; + // #1505: structured-region grouping. A step that ran inside a `loop` / + // `parallel` / `try_catch` body region is tagged by the engine with its + // immediate container so the panel can nest it, instead of showing the + // container as one opaque step. Absent on top-level (main-graph) steps. + parentNodeId?: string; + /** Zero-based loop iteration or parallel branch index of the enclosing region. */ + iteration?: number; + /** Region kind the step ran in: `loop-body` | `parallel-branch` | `try` | `catch`. */ + regionKind?: string; +} + +/** A step plus the region body steps that ran under it — the execution tree the + * panel renders. Reconstructed from the engine's flat, pre-order step log by + * {@link buildStepTree}. */ +export interface StepTreeNode { + step: RunStep; + children: StepTreeNode[]; } /** Run log entry (spec `ExecutionLogSchema`, fields we render). */ @@ -56,6 +77,89 @@ export function errorText(e: RunError | undefined | null): string | undefined { return typeof m === 'string' && m ? m : undefined; } +/** + * Reconstruct the execution tree from the engine's flat, pre-order step log + * (#1505). Each step carries its **immediate** structured-region container in + * `parentNodeId`; the container's own step always precedes its body steps in the + * array, and a whole region's steps are contiguous (the engine appends + * `NodeExecutionResult.childSteps` in one shot). A stack walk therefore rebuilds + * the nesting exactly, and it is robust to the two things that break naive + * grouping: repeated `nodeId`s (a loop body node runs once per iteration) and + * regions nested inside regions. + * + * Degrades safely: a step whose `parentNodeId` has no open ancestor — e.g. a + * container step was dropped by durable-history truncation — is surfaced at the + * top level rather than silently discarded. + */ +export function buildStepTree(steps: RunStep[]): StepTreeNode[] { + const roots: StepTreeNode[] = []; + const stack: StepTreeNode[] = []; + for (const step of steps) { + const node: StepTreeNode = { step, children: [] }; + if (step.parentNodeId == null) { + stack.length = 0; // a top-level step closes every open region + roots.push(node); + } else { + // Pop until the stack top is this step's container. + while (stack.length > 0 && stack[stack.length - 1].step.nodeId !== step.parentNodeId) { + stack.pop(); + } + if (stack.length > 0) { + stack[stack.length - 1].children.push(node); + } else { + roots.push(node); // container not found (truncated log) — don't lose the step + } + } + stack.push(node); // every step may itself contain a nested region + } + return roots; +} + +/** + * Human label for a body step's enclosing region (#1505). `loop`/`parallel` + * carry a zero-based `iteration` surfaced 1-based; `try`/`catch` carry only the + * region kind. Returns `null` for a top-level step (no region grouping). + */ +export function regionLabel(step: RunStep): string | null { + const { regionKind, iteration } = step; + if (!regionKind) return null; + switch (regionKind) { + case 'loop-body': + return iteration == null ? 'Iteration' : `Iteration ${iteration + 1}`; + case 'parallel-branch': + return iteration == null ? 'Branch' : `Branch ${iteration + 1}`; + case 'try': + return 'Try'; + case 'catch': + return 'Catch'; + default: + return iteration == null ? regionKind : `${regionKind} ${iteration + 1}`; + } +} + +/** Grouping key so consecutive body steps of the same iteration/branch/handler + * share one header. */ +function regionSignature(step: RunStep): string { + return `${step.regionKind ?? ''}#${step.iteration ?? ''}`; +} + +/** Split a container's children into consecutive runs that share a region label + * (an iteration, a branch, a try/catch handler), so each gets one header. */ +function groupChildren(children: StepTreeNode[]): { label: string | null; items: StepTreeNode[] }[] { + const groups: { label: string | null; items: StepTreeNode[] }[] = []; + let sig: string | undefined; + for (const child of children) { + const s = regionSignature(child.step); + if (groups.length === 0 || s !== sig) { + groups.push({ label: regionLabel(child.step), items: [child] }); + sig = s; + } else { + groups[groups.length - 1].items.push(child); + } + } + return groups; +} + type LoadState = 'loading' | 'ready' | 'unavailable'; /** Fetch a flow's run history. Exposed for tests. */ @@ -99,7 +203,7 @@ function fmtDuration(ms?: number): string | null { return `${Math.round(ms / 60_000)}m`; } -function StepRow({ step }: { step: RunStep }) { +function StepRow({ step, depth = 0 }: { step: RunStep; depth?: number }) { const cls = step.status === 'success' ? 'text-emerald-600 dark:text-emerald-400' @@ -108,7 +212,7 @@ function StepRow({ step }: { step: RunStep }) { : 'text-muted-foreground'; const stepErr = errorText(step.error); return ( -
  • +
  • {step.status} {step.nodeId} {step.nodeType && {step.nodeType}} @@ -124,6 +228,44 @@ function StepRow({ step }: { step: RunStep }) { ); } +/** Header for a run of body steps in one iteration / branch / try-catch handler. */ +function RegionHeader({ label, depth }: { label: string; depth: number }) { + return ( +
  • + + ↳ + + {label} +
  • + ); +} + +/** Render a step and, nested beneath it, its structured-region body steps — + * grouped by iteration / branch / handler (#1505). Recurses for nested regions. */ +function StepNode({ node, depth }: { node: StepTreeNode; depth: number }) { + const groups = node.children.length > 0 ? groupChildren(node.children) : []; + return ( + <> + + {groups.map((g, gi) => ( + + {g.label != null && } + {g.items.map((child, ci) => ( + + ))} + + ))} + + ); +} + function RunRow({ run }: { run: FlowRun }) { const [open, setOpen] = React.useState(false); const meta = statusMeta(run.status); @@ -164,9 +306,9 @@ function RunRow({ run }: { run: FlowRun }) { {steps.length === 0 ? (
    No step log recorded.
    ) : ( -
      - {steps.map((s, i) => ( - +
        + {buildStepTree(steps).map((node, i) => ( + ))}
      )}