feat(task-execution): show container iteration history in a tree

Adds GET /executions/{id}/node/{stepId}/iterations to the task
executions API and a recursive execution-tree component that lets
users navigate the full iteration history of looping containers
(not just the currently active one), rendered in the left rail
alongside the run list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-07-24 12:46:08 +02:00
parent 8faee0fbaf
commit b1e1a08b13
13 changed files with 843 additions and 14 deletions

View File

@ -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;

View File

@ -1,12 +1,23 @@
<div class="tasks-executor-shell flex flex-col flex-1 h-full">
<div class="flex flex-row flex-1 overflow-hidden gap-2">
<app-tasks-executions-list
class="w-[360px] shrink-0"
[groups]="groups()"
[selectedExecutionId]="selectedExecutionId()"
(executionSelected)="selectExecution($event)"
(executionDeleteRequested)="removeExecution($event)">
</app-tasks-executions-list>
<div class="tasks-executor-rail w-[360px] shrink-0 flex flex-col gap-2 overflow-hidden">
<app-tasks-executions-list
class="tasks-executor-rail-list"
[groups]="groups()"
[selectedExecutionId]="selectedExecutionId()"
(executionSelected)="selectExecution($event)"
(executionDeleteRequested)="removeExecution($event)">
</app-tasks-executions-list>
@if (selectedExecution(); as run) {
<app-execution-tree
class="tasks-executor-rail-tree"
[rootExecution]="run"
[selectedExecutionId]="displayedExecution()?.id ?? null"
(executionSelected)="selectTreeExecution($event)">
</app-execution-tree>
}
</div>
<mat-card class="tasks-executor-viewer flex w-full flex-col overflow-hidden !rounded-md !bg-gray-100">
@if (showExecutionCreationLoader()) {
@ -48,8 +59,8 @@
}
<app-task-execution-viewer
[execution]="displayedExecution()"
[parentExecution]="childExecution() ? selectedExecution() : null"
[parentContainerStep]="childExecution() ? activeSubflowTarget()?.parentStep ?? null : null">
[parentExecution]="displayedParentExecution()"
[parentContainerStep]="displayedParentContainerStep()">
</app-task-execution-viewer>
</mat-card>
</div>

View File

@ -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<TaskExecution | null>(() =>
this.childExecution() ?? this.selectedExecution()
);
/** Execution the user navigated to via the hierarchy tree (any depth). */
readonly manualTreeSelectionId = signal<string | null>(null);
readonly displayedExecution = computed<TaskExecution | null>(() => {
const manual = this.resolveExecutionById(this.manualTreeSelectionId());
if (manual) return manual;
return this.childExecution() ?? this.selectedExecution();
});
readonly displayedParentExecution = computed<TaskExecution | null>(() => {
const displayed = this.displayedExecution();
const parentId = displayed?.parentExecutionId ?? null;
if (!parentId || parentId === displayed?.id) return null;
return this.resolveExecutionById(parentId);
});
readonly displayedParentContainerStep = computed<TaskExecutionStep | null>(() => {
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;

View File

@ -12,6 +12,7 @@ export abstract class TaskExecutionsCallServiceBase {
abstract retrieveAllTaskExecutions(): Observable<TaskExecution[]>;
abstract retrieveTaskExecutionGroups(): Observable<TaskExecutionGroup[]>;
abstract retrieveTaskExecution(executionId: string): Observable<TaskExecution>;
abstract retrieveStepIterations(executionId: string, stepId: string): Observable<TaskExecution[]>;
abstract retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]>;
abstract createTaskExecution(flowId: string): Observable<TaskExecution>;
abstract rerunTaskExecution(executionId: string): Observable<TaskExecution>;

View File

@ -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<TaskExecution[]> {
// 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<ExecutionEventLogEntry[]> {
const execution = this.findExecution(executionId);
return of(this.buildExecutionEvents(execution));

View File

@ -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'],

View File

@ -38,6 +38,13 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
.pipe(map((raw) => this.mapExecution(raw)));
}
override retrieveStepIterations(executionId: string, stepId: string): Observable<TaskExecution[]> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(stepId)}/iterations`;
return this.http.get<unknown>(url).pipe(
map((raw) => Array.isArray(raw) ? raw.map((item) => this.mapExecution(item)) : [])
);
}
override retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]> {
return this.http.get<ExecutionEventLogEntry[]>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/events`);
}

View File

@ -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',

View File

@ -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(

View File

@ -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;
}

View File

@ -0,0 +1,68 @@
@if (rootExecution(); as root) {
@if (hasContainerContent()) {
<div class="execution-tree-shell">
<div class="execution-tree-title">Execution tree</div>
<div class="execution-tree-body">
<ng-container *ngTemplateOutlet="execNode; context: { $implicit: root, depth: 0 }"></ng-container>
</div>
</div>
}
}
<ng-template #execNode let-execution let-depth="depth">
<div
class="tree-row tree-exec-row"
[class.tree-row-selected]="execution.id === selectedExecutionId()"
[style.padding-left.px]="8 + depth * 14"
(click)="selectExecution(execution)">
@if (containerSteps(execution).length) {
<button
type="button"
class="tree-chevron"
[class.tree-chevron-open]="isExecutionExpanded(execution.id)"
(click)="toggleExecution(execution.id, $event)"
aria-label="Toggle">
<span class="tree-chevron-icon">›</span>
</button>
} @else {
<span class="tree-chevron-spacer"></span>
}
<span class="tree-dot" [ngClass]="statusDotClass(execution.context.status)"></span>
<span class="tree-label" [title]="executionLabel(execution)">{{ executionLabel(execution) }}</span>
<span class="tree-status">{{ execution.context.status }}</span>
</div>
@if (isExecutionExpanded(execution.id)) {
@for (step of containerSteps(execution); track step.stepId) {
<div
class="tree-row tree-step-row"
[style.padding-left.px]="8 + (depth + 1) * 14"
(click)="toggleStep(execution.id, step.stepId, $event)">
<span
class="tree-chevron tree-chevron-inline"
[class.tree-chevron-open]="isStepExpanded(execution.id, step.stepId)">
<span class="tree-chevron-icon">›</span>
</span>
<span class="tree-icon">▤</span>
<span class="tree-label" [title]="step.name">{{ step.name }}</span>
@if (step.waitingForSubflow) {
<span class="tree-waiting-badge">waiting</span>
}
</div>
@if (isStepExpanded(execution.id, step.stepId)) {
@if (isStepLoading(execution.id, step.stepId)) {
<div class="tree-hint" [style.padding-left.px]="8 + (depth + 2) * 14">Loading iterations…</div>
} @else if (stepError(execution.id, step.stepId); as err) {
<div class="tree-error" [style.padding-left.px]="8 + (depth + 2) * 14">{{ err }}</div>
} @else if (!iterationsFor(execution.id, step.stepId).length) {
<div class="tree-hint" [style.padding-left.px]="8 + (depth + 2) * 14">No iterations yet</div>
} @else {
@for (iteration of iterationsFor(execution.id, step.stepId); track iteration.id) {
<ng-container *ngTemplateOutlet="execNode; context: { $implicit: iteration, depth: depth + 2 }"></ng-container>
}
}
}
}
}
</ng-template>

View File

@ -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<TaskExecution['context']['steps'][string]> = {}) {
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<ExecutionTreeComponent>;
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);
});
});

View File

@ -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<TaskExecution | null>(null);
readonly selectedExecutionId = input<string | null>(null);
readonly executionSelected = output<ExecutionTreeSelection>();
private readonly expandedKeys = signal<Set<string>>(new Set<string>());
private readonly loadingStepKeys = signal<Set<string>>(new Set<string>());
private readonly stepErrors = signal<Record<string, string>>({});
private readonly iterationsByStep = signal<Record<string, TaskExecution[]>>({});
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<string>([this.execKey(rootId)]) : new Set<string>());
this.iterationsByStep.set({});
this.loadingStepKeys.set(new Set<string>());
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}`;
}
}