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:
parent
8faee0fbaf
commit
b1e1a08b13
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue