diff --git a/src/app/layouts/tasks-executor/tasks-executor.css b/src/app/layouts/tasks-executor/tasks-executor.css
index bbd44d3..510d046 100644
--- a/src/app/layouts/tasks-executor/tasks-executor.css
+++ b/src/app/layouts/tasks-executor/tasks-executor.css
@@ -11,6 +11,20 @@
position: relative;
}
+.tasks-executor-rail {
+ min-height: 0;
+}
+
+.tasks-executor-rail-list {
+ flex: 1 1 auto;
+ min-height: 0;
+}
+
+.tasks-executor-rail-tree {
+ flex: 0 0 auto;
+ max-height: 45%;
+}
+
.interactive-subflow-switcher {
align-items: center;
display: flex;
diff --git a/src/app/layouts/tasks-executor/tasks-executor.html b/src/app/layouts/tasks-executor/tasks-executor.html
index c7696bc..c315233 100644
--- a/src/app/layouts/tasks-executor/tasks-executor.html
+++ b/src/app/layouts/tasks-executor/tasks-executor.html
@@ -1,12 +1,23 @@
-
-
+
+
+
+
+ @if (selectedExecution(); as run) {
+
+
+ }
+
@if (showExecutionCreationLoader()) {
@@ -48,8 +59,8 @@
}
+ [parentExecution]="displayedParentExecution()"
+ [parentContainerStep]="displayedParentContainerStep()">
diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts
index be04fa6..3236b0c 100644
--- a/src/app/layouts/tasks-executor/tasks-executor.ts
+++ b/src/app/layouts/tasks-executor/tasks-executor.ts
@@ -22,6 +22,7 @@ import {
TasksExecutionsListComponent
} from '@shared/tasks-executions-list/tasks-executions-list';
import { TaskExecutionViewerComponent } from '@shared/task-execution-viewer/task-execution-viewer';
+import { ExecutionTreeComponent, ExecutionTreeSelection } from '@shared/execution-tree/execution-tree';
import { formatDuration } from '@shared/task-execution-viewer/execution-viewer.utils';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
@@ -62,7 +63,7 @@ export function findInteractiveSubflowTargets(
@Component({
selector: 'app-tasks-executor',
- imports: [TasksExecutionsListComponent, TaskExecutionViewerComponent, MatCardModule],
+ imports: [TasksExecutionsListComponent, TaskExecutionViewerComponent, ExecutionTreeComponent, MatCardModule],
templateUrl: './tasks-executor.html',
styleUrl: './tasks-executor.css',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -116,9 +117,30 @@ export class TasksExecutor {
const childId = this.activeSubflowTarget()?.childExecutionId;
return childId ? this.taskExecutionsService.followedExecutions()[childId] ?? null : null;
});
- readonly displayedExecution = computed
(() =>
- this.childExecution() ?? this.selectedExecution()
- );
+
+ /** Execution the user navigated to via the hierarchy tree (any depth). */
+ readonly manualTreeSelectionId = signal(null);
+
+ readonly displayedExecution = computed(() => {
+ const manual = this.resolveExecutionById(this.manualTreeSelectionId());
+ if (manual) return manual;
+ return this.childExecution() ?? this.selectedExecution();
+ });
+
+ readonly displayedParentExecution = computed(() => {
+ const displayed = this.displayedExecution();
+ const parentId = displayed?.parentExecutionId ?? null;
+ if (!parentId || parentId === displayed?.id) return null;
+ return this.resolveExecutionById(parentId);
+ });
+
+ readonly displayedParentContainerStep = computed(() => {
+ const displayed = this.displayedExecution();
+ const parent = this.displayedParentExecution();
+ const stepId = displayed?.parentStepId ?? null;
+ if (!parent || !stepId) return null;
+ return parent.context.steps?.[stepId] ?? null;
+ });
readonly showExecutionCreationLoader = computed(() =>
this.pendingExecutionCreation() && !this.requestedExecutionId()
@@ -153,6 +175,11 @@ export class TasksExecutor {
const first = this.groups()[0];
if (first?.latestExecutionId) this.selectedExecutionId.set(first.latestExecutionId);
});
+ effect(() => {
+ // Reset tree navigation whenever the selected top-level run changes.
+ this.selectedExecutionId();
+ untracked(() => this.manualTreeSelectionId.set(null));
+ });
effect(() => {
const targets = this.interactiveSubflowTargets();
const selectedId = this.selectedChildExecutionId();
@@ -205,9 +232,21 @@ export class TasksExecutor {
if (!this.interactiveSubflowTargets().some(
(target) => target.childExecutionId === childExecutionId
)) return;
+ this.manualTreeSelectionId.set(null);
this.selectedChildExecutionId.set(childExecutionId);
}
+ selectTreeExecution(selection: ExecutionTreeSelection) {
+ this.manualTreeSelectionId.set(selection.executionId);
+ }
+
+ private resolveExecutionById(executionId: string | null): TaskExecution | null {
+ if (!executionId) return null;
+ return this.executionDetails().find((execution) => execution.id === executionId)
+ ?? this.taskExecutionsService.followedExecutions()[executionId]
+ ?? null;
+ }
+
async removeExecution(id: string) {
const confirmed = await this.confirm.open('Are you sure you want to delete this execution?');
if (!confirmed) return;
diff --git a/src/app/services/task-executions/task-executions-call.base.ts b/src/app/services/task-executions/task-executions-call.base.ts
index 4d5b34e..39076f0 100644
--- a/src/app/services/task-executions/task-executions-call.base.ts
+++ b/src/app/services/task-executions/task-executions-call.base.ts
@@ -12,6 +12,7 @@ export abstract class TaskExecutionsCallServiceBase {
abstract retrieveAllTaskExecutions(): Observable;
abstract retrieveTaskExecutionGroups(): Observable;
abstract retrieveTaskExecution(executionId: string): Observable;
+ abstract retrieveStepIterations(executionId: string, stepId: string): Observable;
abstract retrieveExecutionEvents(executionId: string): Observable;
abstract createTaskExecution(flowId: string): Observable;
abstract rerunTaskExecution(executionId: string): Observable;
diff --git a/src/app/services/task-executions/task-executions-call.fake.ts b/src/app/services/task-executions/task-executions-call.fake.ts
index e8cbac5..6ea7970 100644
--- a/src/app/services/task-executions/task-executions-call.fake.ts
+++ b/src/app/services/task-executions/task-executions-call.fake.ts
@@ -6,7 +6,7 @@ import {
BiasRerunRequest
} from '@models/bias-impact';
import { ExecutionEventLogEntry, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
-import { map, Observable, of } from 'rxjs';
+import { map, Observable, of, throwError } from 'rxjs';
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase {
@@ -467,6 +467,27 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
return of(this.cloneExecution(this.findExecution(executionId)));
}
+ override retrieveStepIterations(executionId: string, stepId: string): Observable {
+ // Ensures the step exists and is a container, mirroring the backend's 404/400 contract.
+ const execution = this.findExecution(executionId);
+ const step = execution.context.steps?.[stepId];
+ if (!step) {
+ return throwError(() => new Error(`Step ${stepId} not found in execution ${executionId}`));
+ }
+
+ const iterations = this.data
+ .filter((candidate) =>
+ candidate.parentExecutionId === executionId && candidate.parentStepId === stepId
+ )
+ .sort((left, right) =>
+ (left.parentIterationIndex ?? 0) - (right.parentIterationIndex ?? 0)
+ || ((left.creationTime ?? 0) - (right.creationTime ?? 0))
+ )
+ .map((candidate) => this.cloneExecution(candidate));
+
+ return of(iterations);
+ }
+
override retrieveExecutionEvents(executionId: string): Observable {
const execution = this.findExecution(executionId);
return of(this.buildExecutionEvents(execution));
diff --git a/src/app/services/task-executions/task-executions-call.spec.ts b/src/app/services/task-executions/task-executions-call.spec.ts
index e3ea8c7..4a31bb4 100644
--- a/src/app/services/task-executions/task-executions-call.spec.ts
+++ b/src/app/services/task-executions/task-executions-call.spec.ts
@@ -247,6 +247,40 @@ describe('TaskExecutionsCallService bias APIs', () => {
expect(decision.node?.biasAnnotations?.[0].id).toBe('selection-risk');
});
+ it('retrieves the ordered iterations for a looping container step', async () => {
+ const result = firstValueFrom(service.retrieveStepIterations('parent-1', 'container-1'));
+ const request = httpMock.expectOne(
+ `${environment.apiUrl}/executions/parent-1/node/container-1/iterations`
+ );
+ expect(request.request.method).toBe('GET');
+ request.flush([
+ {
+ id: 'child-main-1',
+ executionKind: 'SUBFLOW',
+ parentExecutionId: 'parent-1',
+ parentStepId: 'container-1',
+ parentIterationIndex: 1,
+ subflowRole: 'MAIN',
+ context: { status: 'SUCCESS', inputs: {}, result: {}, errors: {}, warnings: {}, waitingSteps: [], steps: {} }
+ },
+ {
+ id: 'child-guard-1',
+ executionKind: 'SUBFLOW',
+ parentExecutionId: 'parent-1',
+ parentStepId: 'container-1',
+ parentIterationIndex: 1,
+ subflowRole: 'GUARD',
+ context: { status: 'SUCCESS', inputs: {}, result: {}, errors: {}, warnings: {}, waitingSteps: [], steps: {} }
+ }
+ ]);
+
+ const iterations = await result;
+ expect(iterations.map((iteration) => iteration.id)).toEqual(['child-main-1', 'child-guard-1']);
+ expect(iterations[0].parentIterationIndex).toBe(1);
+ expect(iterations[0].subflowRole).toBe('MAIN');
+ expect(iterations[1].subflowRole).toBe('GUARD');
+ });
+
it('starts an asynchronous impact experiment and maps the job response', async () => {
const result = firstValueFrom(service.runBiasImpactExperiment('execution-1', 'step-1', {
annotationIds: ['annotation-1'],
diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts
index bc05b87..ea7e5dc 100644
--- a/src/app/services/task-executions/task-executions-call.ts
+++ b/src/app/services/task-executions/task-executions-call.ts
@@ -38,6 +38,13 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
.pipe(map((raw) => this.mapExecution(raw)));
}
+ override retrieveStepIterations(executionId: string, stepId: string): Observable {
+ const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(stepId)}/iterations`;
+ return this.http.get(url).pipe(
+ map((raw) => Array.isArray(raw) ? raw.map((item) => this.mapExecution(item)) : [])
+ );
+ }
+
override retrieveExecutionEvents(executionId: string): Observable {
return this.http.get(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/events`);
}
diff --git a/src/app/services/task-executions/task-executions.spec.ts b/src/app/services/task-executions/task-executions.spec.ts
index 07b93f3..173e3df 100644
--- a/src/app/services/task-executions/task-executions.spec.ts
+++ b/src/app/services/task-executions/task-executions.spec.ts
@@ -160,6 +160,34 @@ describe('TaskExecutionsService bias operations', () => {
expect(service.taskExecutions()).toEqual([]);
});
+ it('caches every iteration returned for a looping container step', async () => {
+ const iterationOne = {
+ id: 'iteration-1',
+ name: 'Iteration 1',
+ creationTime: 1,
+ executionKind: 'SUBFLOW',
+ parentExecutionId: 'parent-1',
+ parentStepId: 'container-1',
+ parentIterationIndex: 1,
+ subflowRole: 'MAIN',
+ context: { inputs: {}, result: {}, errors: {}, warnings: {}, steps: {}, status: 'SUCCESS', waitingSteps: [] }
+ } satisfies TaskExecution;
+ const iterationTwo = {
+ ...iterationOne,
+ id: 'iteration-2',
+ parentIterationIndex: 2
+ } satisfies TaskExecution;
+ service.taskExecutionsCallService = {
+ retrieveStepIterations: vi.fn().mockReturnValue(of([iterationOne, iterationTwo]))
+ } as unknown as typeof service.taskExecutionsCallService;
+
+ const iterations = await lastValueFrom(service.retrieveStepIterations('parent-1', 'container-1'));
+
+ expect(iterations).toEqual([iterationOne, iterationTwo]);
+ expect(service.followedExecutions()['iteration-1']).toEqual(iterationOne);
+ expect(service.followedExecutions()['iteration-2']).toEqual(iterationTwo);
+ });
+
it('retrieves and caches a child execution directly', async () => {
const child = {
id: 'child-1',
diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts
index 14db834..aa2a14b 100644
--- a/src/app/services/task-executions/task-executions.ts
+++ b/src/app/services/task-executions/task-executions.ts
@@ -92,6 +92,20 @@ export class TaskExecutionsService {
);
}
+ retrieveStepIterations(executionId: string, stepId: string) {
+ return this.withRefreshAndErrorHandling(
+ this.taskExecutionsCallService.retrieveStepIterations(executionId, stepId).pipe(
+ tap((iterations) => {
+ for (const iteration of iterations) {
+ this.cacheFollowedExecution(iteration);
+ }
+ })
+ ),
+ 'Retrieve step iterations failed',
+ false
+ );
+ }
+
createExecution(flowId: string) {
this._pendingExecutionCreation.set(true);
return this.taskExecutionsCallService.createTaskExecution(flowId).pipe(
diff --git a/src/app/shared/execution-tree/execution-tree.css b/src/app/shared/execution-tree/execution-tree.css
new file mode 100644
index 0000000..9892f20
--- /dev/null
+++ b/src/app/shared/execution-tree/execution-tree.css
@@ -0,0 +1,171 @@
+:host {
+ display: block;
+}
+
+:host(.execution-tree-empty) {
+ display: none;
+}
+
+.execution-tree-shell {
+ display: flex;
+ flex-direction: column;
+ max-height: 100%;
+ overflow: hidden;
+ background: #ffffff;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+}
+
+.execution-tree-title {
+ padding: 10px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #64748b;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.execution-tree-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 6px 4px;
+}
+
+.tree-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-height: 28px;
+ padding-right: 8px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 13px;
+ color: #334155;
+ user-select: none;
+}
+
+.tree-row:hover {
+ background: #f1f5f9;
+}
+
+.tree-row-selected {
+ background: #e0edff;
+ color: #1e3a8a;
+}
+
+.tree-row-selected:hover {
+ background: #d4e5ff;
+}
+
+.tree-step-row {
+ color: #475569;
+ font-size: 12.5px;
+}
+
+.tree-chevron {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 16px;
+ height: 16px;
+ flex: 0 0 auto;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: #94a3b8;
+ cursor: pointer;
+}
+
+.tree-chevron-inline {
+ cursor: inherit;
+}
+
+.tree-chevron-icon {
+ display: inline-block;
+ font-size: 14px;
+ line-height: 1;
+ transition: transform 0.12s ease;
+}
+
+.tree-chevron-open .tree-chevron-icon {
+ transform: rotate(90deg);
+}
+
+.tree-chevron-spacer {
+ display: inline-block;
+ width: 16px;
+ flex: 0 0 auto;
+}
+
+.tree-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ flex: 0 0 auto;
+ background: #cbd5e1;
+}
+
+.tree-dot-success { background: #10b981; }
+.tree-dot-error { background: #f43f5e; }
+.tree-dot-cancelled { background: #94a3b8; }
+.tree-dot-suspended { background: #8b5cf6; }
+.tree-dot-waiting { background: #f59e0b; }
+.tree-dot-running {
+ background: #3b82f6;
+ animation: tree-pulse 1.2s ease-in-out infinite;
+}
+
+@keyframes tree-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.35; }
+}
+
+.tree-icon {
+ flex: 0 0 auto;
+ font-size: 12px;
+ color: #94a3b8;
+}
+
+.tree-label {
+ flex: 1 1 auto;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.tree-status {
+ flex: 0 0 auto;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: #94a3b8;
+}
+
+.tree-waiting-badge {
+ flex: 0 0 auto;
+ font-size: 10px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: #b45309;
+ background: #fef3c7;
+ border-radius: 999px;
+ padding: 1px 6px;
+}
+
+.tree-hint,
+.tree-error {
+ min-height: 24px;
+ display: flex;
+ align-items: center;
+ padding-right: 8px;
+ font-size: 12px;
+ font-style: italic;
+ color: #94a3b8;
+}
+
+.tree-error {
+ color: #e11d48;
+ font-style: normal;
+}
diff --git a/src/app/shared/execution-tree/execution-tree.html b/src/app/shared/execution-tree/execution-tree.html
new file mode 100644
index 0000000..c83c470
--- /dev/null
+++ b/src/app/shared/execution-tree/execution-tree.html
@@ -0,0 +1,68 @@
+@if (rootExecution(); as root) {
+@if (hasContainerContent()) {
+
+}
+}
+
+
+
+ @if (containerSteps(execution).length) {
+
+ } @else {
+
+ }
+
+ {{ executionLabel(execution) }}
+ {{ execution.context.status }}
+
+
+ @if (isExecutionExpanded(execution.id)) {
+ @for (step of containerSteps(execution); track step.stepId) {
+
+
+ ›
+
+ ▤
+ {{ step.name }}
+ @if (step.waitingForSubflow) {
+ waiting
+ }
+
+
+ @if (isStepExpanded(execution.id, step.stepId)) {
+ @if (isStepLoading(execution.id, step.stepId)) {
+ Loading iterations…
+ } @else if (stepError(execution.id, step.stepId); as err) {
+ {{ err }}
+ } @else if (!iterationsFor(execution.id, step.stepId).length) {
+ No iterations yet
+ } @else {
+ @for (iteration of iterationsFor(execution.id, step.stepId); track iteration.id) {
+
+ }
+ }
+ }
+ }
+ }
+
diff --git a/src/app/shared/execution-tree/execution-tree.spec.ts b/src/app/shared/execution-tree/execution-tree.spec.ts
new file mode 100644
index 0000000..6b6af3e
--- /dev/null
+++ b/src/app/shared/execution-tree/execution-tree.spec.ts
@@ -0,0 +1,189 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { TaskExecution } from '@models/task-execution';
+import { BlockType } from '@models/flow';
+import { ContainersService } from '@services/containers/containers';
+import { TaskExecutionsService } from '@services/task-executions/task-executions';
+import { of, throwError } from 'rxjs';
+import { vi } from 'vitest';
+
+import { ExecutionTreeComponent } from './execution-tree';
+
+function containerStep(overrides: Partial = {}) {
+ return {
+ id: 'container-1',
+ status: 'SUCCESS',
+ simulated: false,
+ node: { id: 'container-1', name: 'Loop container', nodeFamily: 'container' as const, typeName: 'LoopContainer', inputs: [], outputs: [], specificConfiguration: {} },
+ activeInnerExecutionId: 'iteration-3',
+ containerIterationIndex: 3,
+ ...overrides
+ };
+}
+
+function rootExecution(steps: TaskExecution['context']['steps'] = {}): TaskExecution {
+ return {
+ id: 'root-1',
+ name: 'Root run',
+ creationTime: 1,
+ context: {
+ inputs: {},
+ result: {},
+ errors: {},
+ warnings: {},
+ waitingSteps: [],
+ status: 'RUNNING',
+ steps
+ }
+ };
+}
+
+function iteration(id: string, index: number, subflowRole: string | null = null): TaskExecution {
+ return {
+ id,
+ name: `Iteration ${index}`,
+ creationTime: index,
+ executionKind: 'SUBFLOW',
+ parentExecutionId: 'root-1',
+ parentStepId: 'container-1',
+ parentIterationIndex: index,
+ subflowRole: subflowRole ?? undefined,
+ context: {
+ inputs: {},
+ result: {},
+ errors: {},
+ warnings: {},
+ waitingSteps: [],
+ status: 'SUCCESS',
+ steps: {}
+ }
+ };
+}
+
+describe('ExecutionTreeComponent', () => {
+ let fixture: ComponentFixture;
+ let component: ExecutionTreeComponent;
+ const retrieveStepIterations = vi.fn();
+
+ beforeEach(async () => {
+ retrieveStepIterations.mockReset();
+ await TestBed.configureTestingModule({
+ imports: [ExecutionTreeComponent],
+ providers: [
+ { provide: ContainersService, useValue: { peekContainerType: () => null as BlockType | null } },
+ { provide: TaskExecutionsService, useValue: { retrieveStepIterations } }
+ ]
+ }).compileComponents();
+ fixture = TestBed.createComponent(ExecutionTreeComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('reports no container content for a flat execution with only block steps', () => {
+ const execution = rootExecution({
+ 'block-1': { id: 'block-1', status: 'SUCCESS', simulated: false, node: { id: 'block-1', name: 'LLM', nodeFamily: 'block', typeName: 'LLMBlock', inputs: [], outputs: [], specificConfiguration: {} } }
+ });
+ fixture.componentRef.setInput('rootExecution', execution);
+ fixture.detectChanges();
+
+ expect(component.hasContainerContent()).toBe(false);
+ expect(component.containerSteps(execution)).toEqual([]);
+ });
+
+ it('lists container steps and flags WAITING_FOR_SUBFLOW ones', () => {
+ const execution = rootExecution({ 'container-1': containerStep({ status: 'WAITING_FOR_SUBFLOW' }) });
+ fixture.componentRef.setInput('rootExecution', execution);
+ fixture.detectChanges();
+
+ const steps = component.containerSteps(execution);
+ expect(steps).toEqual([{
+ stepId: 'container-1',
+ name: 'Loop container',
+ status: 'WAITING_FOR_SUBFLOW',
+ waitingForSubflow: true
+ }]);
+ expect(component.hasContainerContent()).toBe(true);
+ });
+
+ it('lazily loads iterations only once, in order, on expand', () => {
+ retrieveStepIterations.mockReturnValue(of([iteration('it-1', 1, 'MAIN'), iteration('it-2', 2, 'MAIN')]));
+ const execution = rootExecution({ 'container-1': containerStep() });
+ fixture.componentRef.setInput('rootExecution', execution);
+ fixture.detectChanges();
+
+ expect(component.isStepLoaded('root-1', 'container-1')).toBe(false);
+
+ component.toggleStep('root-1', 'container-1');
+ fixture.detectChanges();
+
+ expect(retrieveStepIterations).toHaveBeenCalledWith('root-1', 'container-1');
+ expect(retrieveStepIterations).toHaveBeenCalledTimes(1);
+ expect(component.isStepExpanded('root-1', 'container-1')).toBe(true);
+ expect(component.iterationsFor('root-1', 'container-1').map((it) => it.id)).toEqual(['it-1', 'it-2']);
+
+ component.toggleStep('root-1', 'container-1');
+ component.toggleStep('root-1', 'container-1');
+ fixture.detectChanges();
+
+ expect(retrieveStepIterations).toHaveBeenCalledTimes(1);
+ });
+
+ it('filters out GUARD subflows from a LoopContainer iteration list', () => {
+ retrieveStepIterations.mockReturnValue(of([
+ iteration('main-1', 1, 'MAIN'),
+ iteration('guard-1', 1, 'GUARD'),
+ iteration('main-2', 2, 'MAIN'),
+ iteration('guard-2', 2, 'GUARD')
+ ]));
+ const execution = rootExecution({ 'container-1': containerStep() });
+ fixture.componentRef.setInput('rootExecution', execution);
+ fixture.detectChanges();
+
+ component.toggleStep('root-1', 'container-1');
+ fixture.detectChanges();
+
+ expect(component.iterationsFor('root-1', 'container-1').map((it) => it.id)).toEqual(['main-1', 'main-2']);
+ });
+
+ it('surfaces a friendly message when the step is not a container', () => {
+ retrieveStepIterations.mockReturnValue(throwError(() => ({ status: 400 })));
+ const execution = rootExecution({ 'container-1': containerStep() });
+ fixture.componentRef.setInput('rootExecution', execution);
+ fixture.detectChanges();
+
+ component.toggleStep('root-1', 'container-1');
+ fixture.detectChanges();
+
+ expect(component.stepError('root-1', 'container-1')).toBe('This step is not an iterating container.');
+ expect(component.isStepLoading('root-1', 'container-1')).toBe(false);
+ });
+
+ it('emits the selected execution id on click', () => {
+ const execution = rootExecution({ 'container-1': containerStep() });
+ fixture.componentRef.setInput('rootExecution', execution);
+ fixture.detectChanges();
+
+ const selected = vi.fn();
+ component.executionSelected.subscribe(selected);
+ component.selectExecution(execution);
+
+ expect(selected).toHaveBeenCalledWith({ executionId: 'root-1' });
+ });
+
+ it('resets expansion and cached iterations when the root execution id changes', () => {
+ retrieveStepIterations.mockReturnValue(of([iteration('it-1', 1, 'MAIN')]));
+ const execution = rootExecution({ 'container-1': containerStep() });
+ fixture.componentRef.setInput('rootExecution', execution);
+ fixture.detectChanges();
+
+ component.toggleStep('root-1', 'container-1');
+ fixture.detectChanges();
+ expect(component.isStepLoaded('root-1', 'container-1')).toBe(true);
+
+ const otherExecution = rootExecution({ 'container-2': containerStep({ id: 'container-2' }) });
+ otherExecution.id = 'root-2';
+ fixture.componentRef.setInput('rootExecution', otherExecution);
+ fixture.detectChanges();
+
+ expect(component.isStepLoaded('root-1', 'container-1')).toBe(false);
+ expect(component.isExecutionExpanded('root-2')).toBe(true);
+ });
+});
diff --git a/src/app/shared/execution-tree/execution-tree.ts b/src/app/shared/execution-tree/execution-tree.ts
new file mode 100644
index 0000000..469e959
--- /dev/null
+++ b/src/app/shared/execution-tree/execution-tree.ts
@@ -0,0 +1,232 @@
+import { CommonModule } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ effect,
+ inject,
+ input,
+ output,
+ signal,
+ untracked
+} from '@angular/core';
+import { TaskExecution, TaskExecutionStep } from '@models/task-execution';
+import { ContainersService } from '@services/containers/containers';
+import { TaskExecutionsService } from '@services/task-executions/task-executions';
+import { take } from 'rxjs';
+
+export type ExecutionTreeSelection = {
+ executionId: string;
+};
+
+export type ContainerStepView = {
+ stepId: string;
+ name: string;
+ status: string;
+ waitingForSubflow: boolean;
+};
+
+/**
+ * A container step spawns a *separate* TaskExecution per iteration, linked back
+ * via `parentExecutionId`/`parentStepId`. The step itself only exposes the
+ * currently active child (`activeInnerExecutionId`); the full set of iterations
+ * is fetched lazily from `GET /executions/{id}/node/{stepId}/iterations` (see
+ * TaskExecutionsService.retrieveStepIterations). Each iteration is itself a full
+ * execution that may contain further container steps, so the tree recurses.
+ */
+@Component({
+ selector: 'app-execution-tree',
+ imports: [CommonModule],
+ templateUrl: './execution-tree.html',
+ styleUrl: './execution-tree.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ host: {
+ '[class.execution-tree-empty]': '!hasContainerContent()'
+ }
+})
+export class ExecutionTreeComponent {
+ private containersService = inject(ContainersService);
+ private taskExecutionsService = inject(TaskExecutionsService);
+
+ readonly rootExecution = input(null);
+ readonly selectedExecutionId = input(null);
+ readonly executionSelected = output();
+
+ private readonly expandedKeys = signal>(new Set());
+ private readonly loadingStepKeys = signal>(new Set());
+ private readonly stepErrors = signal>({});
+ private readonly iterationsByStep = signal>({});
+ private lastRootId: string | null = null;
+
+ readonly hasContainerContent = computed(() => {
+ const root = this.rootExecution();
+ return !!root && this.containerSteps(root).length > 0;
+ });
+
+ constructor() {
+ // Reset the tree only when the top-level run actually changes — polling
+ // hands us a fresh TaskExecution object on every tick with the same id, and
+ // we must not wipe expansion/loaded state each time.
+ effect(() => {
+ const rootId = this.rootExecution()?.id ?? null;
+ if (rootId === this.lastRootId) return;
+ this.lastRootId = rootId;
+ untracked(() => {
+ this.expandedKeys.set(rootId ? new Set([this.execKey(rootId)]) : new Set());
+ this.iterationsByStep.set({});
+ this.loadingStepKeys.set(new Set());
+ this.stepErrors.set({});
+ });
+ });
+ }
+
+ containerSteps(execution: TaskExecution): ContainerStepView[] {
+ return Object.values(execution.context.steps ?? {})
+ .filter((step) => this.isContainerStep(step))
+ .map((step) => ({
+ stepId: step.id,
+ name: step.node?.name?.trim() || step.id,
+ status: String(step.status ?? ''),
+ waitingForSubflow: String(step.status ?? '').toUpperCase() === 'WAITING_FOR_SUBFLOW'
+ }));
+ }
+
+ private isContainerStep(step: TaskExecutionStep): boolean {
+ if (step.node?.nodeFamily === 'container') return true;
+ if (step.activeInnerExecutionId) return true;
+ if (step.containerContinuationPhase) return true;
+ if (typeof step.containerIterationIndex === 'number') return true;
+ const typeName = step.node?.typeName;
+ return !!typeName && !!this.containersService.peekContainerType(typeName);
+ }
+
+ /** Iterations for a step, minus GUARD subflows (debug-only, see backend doc). */
+ iterationsFor(executionId: string, stepId: string): TaskExecution[] {
+ const all = this.iterationsByStep()[this.stepKey(executionId, stepId)] ?? [];
+ return all.filter((iteration) => String(iteration.subflowRole ?? '').toUpperCase() !== 'GUARD');
+ }
+
+ isExecutionExpanded(executionId: string): boolean {
+ return this.expandedKeys().has(this.execKey(executionId));
+ }
+
+ toggleExecution(executionId: string, event?: Event) {
+ event?.stopPropagation();
+ this.toggleKey(this.execKey(executionId));
+ }
+
+ isStepExpanded(executionId: string, stepId: string): boolean {
+ return this.expandedKeys().has(this.stepExpandKey(executionId, stepId));
+ }
+
+ toggleStep(executionId: string, stepId: string, event?: Event) {
+ event?.stopPropagation();
+ const key = this.stepExpandKey(executionId, stepId);
+ const willExpand = !this.expandedKeys().has(key);
+ this.toggleKey(key);
+ if (willExpand) {
+ this.ensureIterationsLoaded(executionId, stepId);
+ }
+ }
+
+ isStepLoading(executionId: string, stepId: string): boolean {
+ return this.loadingStepKeys().has(this.stepKey(executionId, stepId));
+ }
+
+ stepError(executionId: string, stepId: string): string | null {
+ return this.stepErrors()[this.stepKey(executionId, stepId)] ?? null;
+ }
+
+ isStepLoaded(executionId: string, stepId: string): boolean {
+ return this.stepKey(executionId, stepId) in this.iterationsByStep();
+ }
+
+ selectExecution(execution: TaskExecution) {
+ this.executionSelected.emit({ executionId: execution.id });
+ }
+
+ executionLabel(execution: TaskExecution): string {
+ if (execution.id === this.rootExecution()?.id) return execution.name || 'Execution';
+ if (typeof execution.parentIterationIndex === 'number') {
+ return `Iteration ${execution.parentIterationIndex}`;
+ }
+ return execution.name || 'Subflow';
+ }
+
+ statusDotClass(status: string | null | undefined): string {
+ const normalized = String(status ?? '').toUpperCase();
+ if (normalized === 'SUCCESS' || normalized === 'COMPLETED') return 'tree-dot-success';
+ if (normalized === 'ERROR' || normalized === 'FAILED') return 'tree-dot-error';
+ if (normalized === 'CANCELLED') return 'tree-dot-cancelled';
+ if (normalized === 'SUSPENDED') return 'tree-dot-suspended';
+ if (normalized === 'WAITING' || normalized.startsWith('WAITING_')) return 'tree-dot-waiting';
+ if (normalized === 'RUNNING') return 'tree-dot-running';
+ return 'tree-dot-idle';
+ }
+
+ private ensureIterationsLoaded(executionId: string, stepId: string) {
+ const key = this.stepKey(executionId, stepId);
+ if (key in this.iterationsByStep()) return;
+ if (this.loadingStepKeys().has(key)) return;
+
+ this.loadingStepKeys.update((current) => new Set(current).add(key));
+ this.stepErrors.update((current) => {
+ const next = { ...current };
+ delete next[key];
+ return next;
+ });
+
+ this.taskExecutionsService.retrieveStepIterations(executionId, stepId).pipe(take(1)).subscribe({
+ next: (iterations) => {
+ this.iterationsByStep.update((current) => ({ ...current, [key]: iterations }));
+ this.clearLoading(key);
+ },
+ error: (err) => {
+ this.stepErrors.update((current) => ({ ...current, [key]: this.toErrorMessage(err) }));
+ this.clearLoading(key);
+ }
+ });
+ }
+
+ private clearLoading(key: string) {
+ this.loadingStepKeys.update((current) => {
+ const next = new Set(current);
+ next.delete(key);
+ return next;
+ });
+ }
+
+ private toggleKey(key: string) {
+ this.expandedKeys.update((current) => {
+ const next = new Set(current);
+ if (next.has(key)) {
+ next.delete(key);
+ } else {
+ next.add(key);
+ }
+ return next;
+ });
+ }
+
+ private toErrorMessage(err: unknown): string {
+ if (err && typeof err === 'object' && 'status' in err) {
+ const status = (err as { status?: number }).status;
+ if (status === 400) return 'This step is not an iterating container.';
+ if (status === 404) return 'Step no longer exists.';
+ if (status === 403) return 'Not authorized to load iterations.';
+ }
+ return 'Unable to load iterations.';
+ }
+
+ private execKey(executionId: string): string {
+ return `exec:${executionId}`;
+ }
+
+ private stepExpandKey(executionId: string, stepId: string): string {
+ return `step:${this.stepKey(executionId, stepId)}`;
+ }
+
+ private stepKey(executionId: string, stepId: string): string {
+ return `${executionId}::${stepId}`;
+ }
+}