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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/ai-studio/src/components/execution/highlighting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function ExecutionHighlighting() {
const nonIdleNodes = new Set<string>();

for (const [nodeId, state] of Object.entries(nodeStates) as [string, NodeExecutionState][]) {
if (state.status !== 'idle') {
if (state.status !== 'idle' && state.status !== 'skipped') {
nonIdleNodes.add(nodeId);
}

Expand Down
12 changes: 11 additions & 1 deletion apps/ai-studio/src/components/execution/log-panel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@
color: var(--ai-studio-status-color--completed);
}

.badge--node_started {
.badge--node_started,
.badge--node_skipped {
color: var(--ax-txt-secondary, #888);
}

Expand All @@ -108,6 +109,15 @@
opacity: 0.5;
}

.reason {
font-size: 0.65rem;
font-style: italic;
opacity: 0.55;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.time {
margin-left: auto;
font-size: 0.65rem;
Expand Down
9 changes: 8 additions & 1 deletion apps/ai-studio/src/components/execution/log-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@ import { useSingleSelectedElement } from '@workflowbuilder/sdk';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';

import type { ExecutionEvent } from '@workflow-builder/types/workflow-execution/execution-events';
import type { ExecutionEvent, NodeSkipReason } from '@workflow-builder/types/workflow-execution/execution-events';

import styles from './log-panel.module.css';

import { useRightPanelAnchor } from '../../hooks/use-right-panel-anchor';
import { toggleLog, useExecutionStore } from '../../stores/use-execution-store';
import { extractOutputText } from '../../utils/extract-output-text';

const SKIP_REASON_LABEL: Record<NodeSkipReason, string> = {
branch_not_taken: 'branch not taken',
upstream_skipped: 'upstream skipped',
};

const DETAIL_PREVIEW_CHARS = 120;
const NODE_ID_PREVIEW_CHARS = 8;
const AT_BOTTOM_TOLERANCE_PX = 4;
Expand All @@ -25,6 +30,7 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo
const isNode = typeof nodeId === 'string' && nodeId.length > 0;
const isHighlighted = isNode && nodeId === selectedNodeId;
const label = event.type.replaceAll('_', ' ');
const skipReason = event.type === 'node_skipped' ? SKIP_REASON_LABEL[event.payload.reason] : undefined;

let detail: string | undefined;
switch (event.type) {
Expand Down Expand Up @@ -71,6 +77,7 @@ function EventRow({ event, selectedNodeId }: { event: ExecutionEvent; selectedNo
<div className={styles['event-header']}>
<span className={clsx(styles['badge'], styles[`badge--${event.type}`])}>{label}</span>
{isNode && <span className={styles['node-id']}>{nodeId.slice(0, NODE_ID_PREVIEW_CHARS)}</span>}
{skipReason && <span className={styles['reason']}>{skipReason}</span>}
<span className={styles['time']}>{formatTime(event.timestamp)}</span>
{hasDetail && <span className={styles['toggle']}>{isExpanded ? '▲' : '▼'}</span>}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
color: var(--ai-studio-status-color--failed);
}

.icon--skipped {
color: var(--ax-txt-secondary, #888);
opacity: 0.7;
}

.icon--running {
color: var(--ai-studio-status-color--completed);
animation: spin 1.6s linear infinite;
Expand Down
8 changes: 7 additions & 1 deletion apps/ai-studio/src/components/execution/node-markers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export function ExecutionNodeMarkers({ props }: Props) {

if (!nodeState || nodeState.status === 'idle') return null;

const isClickable = nodeState.status === 'completed' || nodeState.status === 'failed';
const isClickable =
nodeState.status === 'completed' || nodeState.status === 'failed' || nodeState.status === 'skipped';

// The click also selects the node on the canvas, which drives the log highlight.
return (
Expand All @@ -40,6 +41,11 @@ export function ExecutionNodeMarkers({ props }: Props) {
<Icon name="WarningDiamond" />
</span>
)}
{nodeState.status === 'skipped' && (
<span className={`${styles['icon']} ${styles['icon--skipped']}`}>
<Icon name="SkipForward" />
</span>
)}
</div>
);
}
6 changes: 5 additions & 1 deletion apps/ai-studio/src/stores/use-execution-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
ExecutionStatus,
} from '@workflow-builder/types/workflow-execution/execution-events';

type NodeExecutionStatus = 'idle' | 'running' | 'completed' | 'failed';
type NodeExecutionStatus = 'idle' | 'running' | 'completed' | 'failed' | 'skipped';

export type NodeExecutionState = {
status: NodeExecutionStatus;
Expand Down Expand Up @@ -107,6 +107,10 @@ function applyEventToNodeStates(event: ExecutionEvent, states: Record<string, No
states[event.nodeId] = { status: 'failed', error: event.payload.error };
break;
}
case 'node_skipped': {
states[event.nodeId] = { status: 'skipped' };
break;
}
}
}

Expand Down
15 changes: 14 additions & 1 deletion packages/execution-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,19 @@ const node: MyNode = {

If a node with `'errorRoute'` policy fails but has no outgoing edge tagged `'errorRoute'`, the run completes cleanly — the failure is recorded as `node_failed` and nothing else fires. That makes `'errorRoute'` usable as a silent DLQ when paired with downstream observability on `node_failed` events.

## Skipped nodes

A node whose every incoming edge resolved without a live route never runs — a decision picked another branch, an `'errorRoute'` failure pruned the success branch, or the node sits downstream of one of those. The runner emits a `node_skipped` event for each, so an operator tailing the stream can tell "this node was never reached" from "this node is still pending".

| `payload.reason` | Meaning |
| -------------------- | ------------------------------------------------------------------------------------------- |
| `'branch_not_taken'` | At least one predecessor ran and routed elsewhere — this is the head of the dead branch. |
| `'upstream_skipped'` | Every predecessor was itself skipped — this node sits deeper inside an already-dead branch. |

The reason does not depend on the order a node's predecessors happen to resolve in: one live-but-pruned incoming edge is enough to make it `'branch_not_taken'`.

Events are emitted once the whole wave has propagated, after that wave's `node_completed` events and before the next wave's `node_started`. A skipped node emits exactly one `node_skipped` and no `node_started`/`node_completed`, so it stays absent from `nodeOutputs` — downstream joins see only the live predecessors' outputs.

## Template references

`resolveTemplate(template, context)` (in `src/templates/`) interpolates `{{namespace.path}}` references against the live `ExecutionContext`. Three forms are supported - **strict by default**, with two opt-in modifiers for missing values:
Expand Down Expand Up @@ -193,7 +206,7 @@ export interface LoggerPort {

### Where logger lives

`LoggerPort` is **not** passed into `runGraph`, and `runGraph` does **not** import it. The runner is re-exported from the sandbox-safe entry (`@workflow-builder/execution-core/workflow`) and runs inside Temporal's V8 workflow context, where every call to `new Date()` poisons history replay. Lifecycle signals (`execution_started/completed/failed`, `node_started/completed/failed`) already flow through `EventEmitterPort` — operators tail those for run-time observability of a workflow.
`LoggerPort` is **not** passed into `runGraph`, and `runGraph` does **not** import it. The runner is re-exported from the sandbox-safe entry (`@workflow-builder/execution-core/workflow`) and runs inside Temporal's V8 workflow context, where every call to `new Date()` poisons history replay. Lifecycle signals (`execution_started/completed/failed`, `node_started/completed/failed`, `node_skipped`) already flow through `EventEmitterPort` — operators tail those for run-time observability of a workflow.

Use `LoggerPort` outside the sandbox — in HTTP routes, in activity executors (LLM calls, HTTP retries), at app startup.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,32 @@ describe('runGraph — replay determinism (re-execution equivalence)', () => {
expect(records[0]!.activityCallOrder).toEqual(['D', 'B']);
});

it('node_skipped — several skips in one wave keep the same order and reasons', async () => {
// The one event a skipped node ever emits, so its position and payload are all a
// replay has to compare. Emission order comes from `propagate`'s breadth-first walk
// over the dead subtree, seeded from `definition.nodes` order — a switch to a Set
// or an emit-as-you-go inside the wave would re-order these without changing
// anything else the runner records.
const input = makeInput(
[start('D'), trigger('Live'), trigger('C1'), trigger('C2'), trigger('C1prime')],
[
edge('e1', 'D', 'Live', 'X'),
edge('e2', 'D', 'C1', 'Y'),
edge('e3', 'D', 'C2', 'Z'),
edge('e4', 'C1', 'C1prime'),
],
);

const records = await runNTimes(input, { D: { output: 'd', nextPort: 'X' } }, RUNS);
expectAllRunsIdentical(records);

expect(records[0]!.events.filter((event) => event.type === 'node_skipped')).toEqual([
{ type: 'node_skipped', nodeId: 'C1', payload: { reason: 'branch_not_taken' } },
{ type: 'node_skipped', nodeId: 'C2', payload: { reason: 'branch_not_taken' } },
{ type: 'node_skipped', nodeId: 'C1prime', payload: { reason: 'upstream_skipped' } },
]);
});

it('node failure — failure path is deterministic too (same error code, same event sequence)', async () => {
// The catch branch builds errorPayload from the thrown error. Across
// replays the activity returns the same error (cached in history), so
Expand Down
78 changes: 76 additions & 2 deletions packages/execution-core/src/graph-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ function edge(id: string, source: string, target: string, sourceHandle?: string)
return { id, sourceNodeId: source, targetNodeId: target, sourceHandle };
}

function skipsFrom(events: EventCall[]): { nodeId: string | undefined; reason: unknown }[] {
return events
.filter((event) => event.type === 'node_skipped')
.map((event) => ({ nodeId: event.nodeId, reason: (event.payload as { reason: string }).reason }));
}

function makeInput(nodes: TestNode[], edges: WorkflowEdgeDefinition[]): WorkflowExecutionInput<TestNode> {
const definition: WorkflowDefinition<TestNode> = { workflowId: 'wf-1', nodes, edges };
return {
Expand Down Expand Up @@ -202,7 +208,7 @@ describe('runGraph — topological scheduling', () => {
expect(runner.contexts.C).toEqual({ S: 'out-S', A: 'out-A', Aprime: 'out-Aprime', B: 'out-B' });
});

it('decision routing — node reachable only via pruned branch is skipped silently', async () => {
it('decision routing — node reachable only via pruned branch is skipped and reported', async () => {
// D is a decision picking branch X. C is reachable only via Y → must be skipped.
const runner = makeRunner({
D: { output: { matchedBranch: 'X' }, nextPort: 'X' },
Expand All @@ -216,12 +222,37 @@ describe('runGraph — topological scheduling', () => {
);

expect(runner.callOrder).toEqual(['D', 'B']);
// No node_started for C
// C never ran, but the run says so out loud rather than leaving it indistinguishable
// from a node still pending. D ran and chose another handle → branch_not_taken.
expect(events.events.some((event) => event.type === 'node_started' && event.nodeId === 'C')).toBe(false);
expect(skipsFrom(events.events)).toEqual([{ nodeId: 'C', reason: 'branch_not_taken' }]);
// Graph still completes successfully
expect(events.statuses.at(-1)?.status).toBe('completed');
});

it('node_skipped lands after the wave that pruned it and before the next wave starts', async () => {
// Ordering is the contract a log panel renders against: the skip must read as a
// consequence of D's completion, not arrive interleaved with B's execution.
const runner = makeRunner({ D: { output: 'd', nextPort: 'X' } });
const events = makeEvents();

await runGraph(
makeInput([start('D'), trigger('B'), trigger('C')], [edge('e1', 'D', 'B', 'X'), edge('e2', 'D', 'C', 'Y')]),
runner.port,
events.port,
);

expect(events.events.map((event) => `${event.type}:${event.nodeId ?? '-'}`)).toEqual([
'execution_started:-',
'node_started:D',
'node_completed:D',
'node_skipped:C',
'node_started:B',
'node_completed:B',
'execution_completed:-',
]);
});

it('decision-pruned fan-in — join executes with only the live predecessor', async () => {
// D picks X → B runs, C is skipped. E joins B and C — should run with only B's output.
const runner = makeRunner({
Expand Down Expand Up @@ -268,6 +299,42 @@ describe('runGraph — topological scheduling', () => {
// Only D, B, and E execute
expect(runner.callOrder.sort()).toEqual(['B', 'D', 'E']);
expect(runner.contexts.E).toEqual({ D: 'd', B: 'out-B' });
// C is the head of the dead branch (D ran, chose X); Cprime is deeper inside it.
expect(skipsFrom(events.events)).toEqual([
{ nodeId: 'C', reason: 'branch_not_taken' },
{ nodeId: 'Cprime', reason: 'upstream_skipped' },
]);
});

it("skip reason ignores the order a join's predecessors resolve in", async () => {
// J joins two dead edges of different kinds: C→J (C was itself skipped) resolves
// first, then B→J (B ran and routed to K instead). One live-but-pruned incoming
// edge is enough, so J reports branch_not_taken regardless of which edge lands
// last — a "whichever predecessor resolved last wins" rule would call it
// upstream_skipped here.
const runner = makeRunner({ D: { output: 'd', nextPort: 'X' }, B: { output: 'b', nextPort: 'P' } });
const events = makeEvents();

await runGraph(
makeInput(
[start('D'), trigger('B'), trigger('C'), trigger('J'), trigger('K')],
[
edge('e1', 'D', 'B', 'X'),
edge('e2', 'D', 'C', 'Y'),
edge('e3', 'C', 'J'),
edge('e4', 'B', 'J', 'Q'),
edge('e5', 'B', 'K', 'P'),
],
),
runner.port,
events.port,
);

expect(runner.callOrder).toEqual(['D', 'B', 'K']);
expect(skipsFrom(events.events)).toEqual([
{ nodeId: 'C', reason: 'branch_not_taken' },
{ nodeId: 'J', reason: 'branch_not_taken' },
]);
});

it('failure short-circuits the graph — emits execution_failed and stops', async () => {
Expand Down Expand Up @@ -714,6 +781,9 @@ describe('runGraph — errorPolicy', () => {

expect(runner.callOrder).toEqual(['A', 'B', 'Recovery']);
expect(events.events.some((event) => event.type === 'node_started' && event.nodeId === 'Success')).toBe(false);
// B ran (and failed) before routing to the error handle, so the pruned success
// branch is a branch not taken — not an upstream skip.
expect(skipsFrom(events.events)).toEqual([{ nodeId: 'Success', reason: 'branch_not_taken' }]);
expect(runner.contexts.Recovery).toEqual({ A: 'out-A', B: { error: { message: 'boom' } } });
expect(events.statuses.at(-1)?.status).toBe('completed');
expect(outcome).toEqual({ status: 'completed' });
Expand Down Expand Up @@ -748,6 +818,10 @@ describe('runGraph — errorPolicy', () => {
);

expect(runner.callOrder).toEqual(['A', 'B', 'Recovery', 'Done']);
expect(skipsFrom(events.events)).toEqual([
{ nodeId: 'Success', reason: 'branch_not_taken' },
{ nodeId: 'SuccessPrime', reason: 'upstream_skipped' },
]);
expect(events.statuses.at(-1)?.status).toBe('completed');
});

Expand Down
Loading
Loading