diff --git a/src/app/layouts/tasks-executor/tasks-executor.css b/src/app/layouts/tasks-executor/tasks-executor.css
index 6241b77..bbd44d3 100644
--- a/src/app/layouts/tasks-executor/tasks-executor.css
+++ b/src/app/layouts/tasks-executor/tasks-executor.css
@@ -11,6 +11,45 @@
position: relative;
}
+.interactive-subflow-switcher {
+ align-items: center;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 8px 12px;
+ border-bottom: 1px solid #bae6fd;
+ background: #f0f9ff;
+ color: #0c4a6e;
+ font-size: 12px;
+}
+
+.interactive-subflow-switcher button {
+ padding: 4px 9px;
+ border: 1px solid #7dd3fc;
+ border-radius: 999px;
+ background: #fff;
+ color: #075985;
+}
+
+.interactive-subflow-switcher button.interactive-subflow-switcher-active {
+ border-color: #0284c7;
+ background: #0284c7;
+ color: #fff;
+}
+
+.interactive-subflow-error {
+ position: absolute;
+ z-index: 3;
+ top: 10px;
+ right: 10px;
+ padding: 8px 12px;
+ border: 1px solid #fecdd3;
+ border-radius: 8px;
+ background: #fff1f2;
+ color: #9f1239;
+ font-size: 12px;
+}
+
.tasks-executor-loader {
position: absolute;
inset: 0;
diff --git a/src/app/layouts/tasks-executor/tasks-executor.html b/src/app/layouts/tasks-executor/tasks-executor.html
index c5e2a60..c7696bc 100644
--- a/src/app/layouts/tasks-executor/tasks-executor.html
+++ b/src/app/layouts/tasks-executor/tasks-executor.html
@@ -18,7 +18,39 @@
}
-
+ @if (interactiveSubflowTargets().length > 1) {
+
+ Interactive containers waiting:
+ @for (target of interactiveSubflowTargets(); track target.childExecutionId) {
+
+ }
+
+ }
+ @if (childExecutionLoading()) {
+
+
+
+
Opening interactive subflow…
+
Loading the active child execution.
+
+
+ }
+ @if (childExecutionError(); as childError) {
+ {{ childError }}
+ }
+
+
diff --git a/src/app/layouts/tasks-executor/tasks-executor.spec.ts b/src/app/layouts/tasks-executor/tasks-executor.spec.ts
index 115e5a7..706828b 100644
--- a/src/app/layouts/tasks-executor/tasks-executor.spec.ts
+++ b/src/app/layouts/tasks-executor/tasks-executor.spec.ts
@@ -8,10 +8,11 @@ import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { FieldRetriever } from '@services/retriever/field-retriever';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
+import { normalizeExecutionStatus, TaskExecution } from '@models/task-execution';
import { of } from 'rxjs';
import { vi } from 'vitest';
-import { TasksExecutor } from './tasks-executor';
+import { findInteractiveSubflowTargets, TasksExecutor } from './tasks-executor';
describe('TasksExecutor', () => {
let component: TasksExecutor;
@@ -42,8 +43,10 @@ describe('TasksExecutor', () => {
useValue: {
taskExecutions: signal([]),
taskExecutionGroups: signal([]),
+ followedExecutions: signal({}),
pendingExecutionCreation: signal(false),
init: vi.fn(),
+ retrieveExecution: vi.fn().mockReturnValue(of(null)),
deleteExecution: vi.fn().mockReturnValue(of(null)),
rerunExecution: vi.fn().mockReturnValue(of(null))
}
@@ -101,4 +104,97 @@ describe('TasksExecutor', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ it('follows only container steps waiting for a child subflow', () => {
+ const execution = {
+ id: 'parent-1',
+ name: 'Parent',
+ creationTime: 1,
+ context: {
+ inputs: {},
+ result: {},
+ errors: {},
+ warnings: [],
+ status: 'WAITING',
+ waitingSteps: ['container-1'],
+ steps: {
+ 'container-1': {
+ id: 'container-1',
+ status: 'WAITING_FOR_SUBFLOW',
+ simulated: false,
+ activeInnerExecutionId: 'child-2',
+ containerContinuationPhase: 'WAITING_FOR_SUBFLOW',
+ containerIterationIndex: 2,
+ node: {
+ id: 'container-1',
+ name: 'Review loop',
+ inputs: [],
+ outputs: [],
+ typeName: 'LoopContainer',
+ nodeFamily: 'container',
+ specificConfiguration: {}
+ }
+ },
+ 'human-1': {
+ id: 'human-1',
+ status: 'WAITING_FOR_INTERACTION',
+ simulated: false
+ }
+ }
+ }
+ } satisfies TaskExecution;
+
+ expect(findInteractiveSubflowTargets(execution)).toEqual([
+ expect.objectContaining({
+ childExecutionId: 'child-2',
+ parentStepId: 'container-1',
+ containerName: 'Review loop',
+ iterationIndex: 2
+ })
+ ]);
+ });
+
+ it('detects a new child id as the next container iteration', () => {
+ const step = {
+ id: 'iterator-1',
+ status: 'WAITING_FOR_SUBFLOW',
+ simulated: false,
+ activeInnerExecutionId: 'child-1',
+ containerIterationIndex: 1
+ };
+ const execution = {
+ id: 'parent-1',
+ name: 'Parent',
+ creationTime: 1,
+ context: {
+ inputs: {},
+ result: {},
+ errors: {},
+ warnings: [],
+ status: 'WAITING',
+ waitingSteps: ['iterator-1'],
+ steps: { 'iterator-1': step }
+ }
+ } satisfies TaskExecution;
+
+ expect(findInteractiveSubflowTargets(execution)[0]?.childExecutionId).toBe('child-1');
+ const nextExecution = {
+ ...execution,
+ context: {
+ ...execution.context,
+ steps: {
+ 'iterator-1': {
+ ...step,
+ activeInnerExecutionId: 'child-2',
+ containerIterationIndex: 2
+ }
+ }
+ }
+ } satisfies TaskExecution;
+ expect(findInteractiveSubflowTargets(nextExecution)[0]).toEqual(expect.objectContaining({
+ childExecutionId: 'child-2',
+ iterationIndex: 2
+ }));
+ expect(normalizeExecutionStatus('WAITING_FOR_SUBFLOW')).toBe('WAITING');
+ });
});
diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts
index 4d5ff99..be04fa6 100644
--- a/src/app/layouts/tasks-executor/tasks-executor.ts
+++ b/src/app/layouts/tasks-executor/tasks-executor.ts
@@ -1,6 +1,19 @@
-import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ effect,
+ inject,
+ signal,
+ untracked
+} from '@angular/core';
import { MatCardModule } from '@angular/material/card';
-import { normalizeExecutionStatus, TaskExecution, TaskExecutionGroup } from '@models/task-execution';
+import {
+ normalizeExecutionStatus,
+ TaskExecution,
+ TaskExecutionGroup,
+ TaskExecutionStep
+} from '@models/task-execution';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import {
@@ -14,6 +27,38 @@ import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
+import { catchError, EMPTY, exhaustMap, timer } from 'rxjs';
+
+export type InteractiveSubflowTarget = {
+ childExecutionId: string;
+ parentStep: TaskExecutionStep;
+ parentStepId: string;
+ containerName: string;
+ iterationIndex: number | null;
+};
+
+export function findInteractiveSubflowTargets(
+ execution: TaskExecution | null | undefined
+): InteractiveSubflowTarget[] {
+ if (!execution) return [];
+
+ return Object.entries(execution.context.steps ?? {}).flatMap(([stepId, step]) => {
+ const childExecutionId = String(step.activeInnerExecutionId ?? '').trim();
+ if (String(step.status ?? '').toUpperCase() !== 'WAITING_FOR_SUBFLOW' || !childExecutionId) {
+ return [];
+ }
+
+ return [{
+ childExecutionId,
+ parentStep: step,
+ parentStepId: step.id || stepId,
+ containerName: step.node?.name?.trim() || step.id || stepId,
+ iterationIndex: typeof step.containerIterationIndex === 'number'
+ ? step.containerIterationIndex
+ : null
+ }];
+ });
+}
@Component({
selector: 'app-tasks-executor',
@@ -23,6 +68,7 @@ import { TaskExecutionsService } from '@services/task-executions/task-executions
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TasksExecutor {
+ private static readonly CHILD_POLL_INTERVAL_MS = 2_000;
private taskExecutionsService = inject(TaskExecutionsService);
private confirm = inject(ConfirmDialogService);
private blocksService = inject(BlocksService);
@@ -53,6 +99,27 @@ export class TasksExecutor {
return details.find((execution) => execution.id === selectedId) ?? null;
});
+ readonly interactiveSubflowTargets = computed(() =>
+ findInteractiveSubflowTargets(this.selectedExecution())
+ );
+
+ readonly selectedChildExecutionId = signal(null);
+ readonly childExecutionLoading = signal(false);
+ readonly childExecutionError = signal(null);
+ readonly activeSubflowTarget = computed(() => {
+ const selectedId = this.selectedChildExecutionId();
+ return this.interactiveSubflowTargets()
+ .find((target) => target.childExecutionId === selectedId)
+ ?? null;
+ });
+ readonly childExecution = computed(() => {
+ const childId = this.activeSubflowTarget()?.childExecutionId;
+ return childId ? this.taskExecutionsService.followedExecutions()[childId] ?? null : null;
+ });
+ readonly displayedExecution = computed(() =>
+ this.childExecution() ?? this.selectedExecution()
+ );
+
readonly showExecutionCreationLoader = computed(() =>
this.pendingExecutionCreation() && !this.requestedExecutionId()
);
@@ -86,6 +153,41 @@ export class TasksExecutor {
const first = this.groups()[0];
if (first?.latestExecutionId) this.selectedExecutionId.set(first.latestExecutionId);
});
+ effect(() => {
+ const targets = this.interactiveSubflowTargets();
+ const selectedId = this.selectedChildExecutionId();
+ if (selectedId && targets.some((target) => target.childExecutionId === selectedId)) return;
+ this.selectedChildExecutionId.set(targets[0]?.childExecutionId ?? null);
+ });
+ effect((onCleanup) => {
+ const childExecutionId = this.activeSubflowTarget()?.childExecutionId ?? null;
+ if (!childExecutionId) {
+ this.childExecutionLoading.set(false);
+ this.childExecutionError.set(null);
+ return;
+ }
+
+ this.childExecutionLoading.set(
+ !untracked(() => this.taskExecutionsService.followedExecutions()[childExecutionId])
+ );
+ this.childExecutionError.set(null);
+ const subscription = timer(0, TasksExecutor.CHILD_POLL_INTERVAL_MS).pipe(
+ exhaustMap(() => this.taskExecutionsService.retrieveExecution(childExecutionId).pipe(
+ catchError(() => {
+ this.childExecutionLoading.set(false);
+ this.childExecutionError.set(
+ 'Unable to load the interactive subflow execution. Retrying…'
+ );
+ return EMPTY;
+ })
+ ))
+ ).subscribe(() => {
+ this.childExecutionLoading.set(false);
+ this.childExecutionError.set(null);
+ });
+
+ onCleanup(() => subscription.unsubscribe());
+ });
}
selectExecution(id: string) {
@@ -99,6 +201,13 @@ export class TasksExecutor {
});
}
+ selectInteractiveSubflow(childExecutionId: string) {
+ if (!this.interactiveSubflowTargets().some(
+ (target) => target.childExecutionId === childExecutionId
+ )) return;
+ this.selectedChildExecutionId.set(childExecutionId);
+ }
+
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/models/bias-impact.spec.ts b/src/app/models/bias-impact.spec.ts
index 6330953..a78f85e 100644
--- a/src/app/models/bias-impact.spec.ts
+++ b/src/app/models/bias-impact.spec.ts
@@ -98,5 +98,8 @@ describe('bias impact models', () => {
expect(BIAS_PROBE_ERROR_CODES).toContain('BIAS_PROBE_MOCK_OUTPUT_TYPE_MISMATCH');
expect(BIAS_EXPERIMENT_ERROR_CODES).toContain('BIAS_SIDE_EFFECT_CONFIRMATION_REQUIRED');
expect(BIAS_EXPERIMENT_ERROR_CODES).toContain('BIAS_EXECUTION_HISTORY_MISMATCH');
+ expect(BIAS_EXPERIMENT_ERROR_CODES).toContain('BIAS_SUBFLOW_ON_NON_CONTAINER');
+ expect(BIAS_EXPERIMENT_ERROR_CODES).toContain('BIAS_SUBFLOW_NOT_EXECUTABLE');
+ expect(BIAS_EXPERIMENT_ERROR_CODES).toContain('BIAS_ACTIVATION_ANNOTATIONS_REQUIRED');
});
});
diff --git a/src/app/models/bias-impact.ts b/src/app/models/bias-impact.ts
index 6890195..ac127c4 100644
--- a/src/app/models/bias-impact.ts
+++ b/src/app/models/bias-impact.ts
@@ -23,6 +23,7 @@ export type BiasImpactExperimentRequest = {
export type BiasRerunActivation = {
nodeId: string;
annotationIds: string[];
+ includeSubflow?: boolean;
};
export type BiasRerunRequest = {
@@ -141,6 +142,9 @@ export const BIAS_EXPERIMENT_ERROR_CODES = [
'BIAS_EXECUTION_HISTORY_MISMATCH',
'BIAS_SIDE_EFFECT_BLOCKED',
'BIAS_SIDE_EFFECT_CONFIRMATION_REQUIRED',
+ 'BIAS_SUBFLOW_ON_NON_CONTAINER',
+ 'BIAS_SUBFLOW_NOT_EXECUTABLE',
+ 'BIAS_ACTIVATION_ANNOTATIONS_REQUIRED',
'BIAS_JOB_NOT_FOUND',
'BIAS_REPORT_NOT_FOUND',
'BIAS_EXPERIMENT_FAILED'
diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts
index ec93453..e2ae9ce 100644
--- a/src/app/models/task-execution.ts
+++ b/src/app/models/task-execution.ts
@@ -4,7 +4,28 @@ import { BiasExecutionContext } from './bias-impact';
export type TaskExecutionStatus = 'CREATED' | 'READY' | 'RUNNING' | 'WAITING' | 'SUSPENDED' | 'SUCCESS' | 'ERROR' | 'CANCELLED';
export type TaskExecutionStatusGroup = 'INIT' | 'RUNNING' | 'PAUSED' | 'FINAL';
-export type StepStatus = 'WAITING_FOR_INPUT' | 'WAITING_FOR_DEPENDENCY' | 'FAILED' | 'COMPLETED' | 'RUNNING' | string;
+export type StepStatus =
+ | 'WAITING_FOR_INPUT'
+ | 'WAITING_FOR_DEPENDENCY'
+ | 'WAITING_FOR_INTERACTION'
+ | 'WAITING_FOR_SUBFLOW'
+ | 'READY'
+ | 'RUNNING'
+ | 'COMPLETED'
+ | 'SKIPPED'
+ | 'FAILED'
+ | 'CANCELLED'
+ | string;
+
+export type ContainerContinuationPhase =
+ | 'CHILD_CREATED'
+ | 'CHILD_RUNNING'
+ | 'WAITING_FOR_SUBFLOW'
+ | 'CHILD_COMPLETED'
+ | string;
+
+export type ExecutionKind = 'TOP_LEVEL' | 'SUBFLOW' | string;
+export type SubflowRole = 'MAIN' | 'GUARD' | string;
export type ExecutionEventLogEntry = {
id: string;
@@ -26,6 +47,11 @@ export type TaskExecution = {
sourceFlowId?: string | null;
runNumber?: number | null;
rerunOfExecutionId?: string | null;
+ executionKind?: ExecutionKind;
+ parentExecutionId?: string | null;
+ parentStepId?: string | null;
+ parentIterationIndex?: number | null;
+ subflowRole?: SubflowRole | null;
biasExecutionContext?: BiasExecutionContext;
context: TaskExecutionContext;
interactionSimulationEnabled?: boolean;
@@ -99,6 +125,9 @@ export type TaskExecutionStep = {
started?: boolean;
skipReason?: string | null;
simulated: boolean;
+ activeInnerExecutionId?: string | null;
+ containerContinuationPhase?: ContainerContinuationPhase | null;
+ containerIterationIndex?: number | null;
};
export type TaskExecutionStepInput = {
@@ -143,6 +172,7 @@ export function normalizeExecutionStatus(status: string | null | undefined): Tas
normalized === 'WAITING' ||
normalized === 'WAITING_FOR_INPUT' ||
normalized === 'WAITING_FOR_INTERACTION' ||
+ normalized === 'WAITING_FOR_SUBFLOW' ||
normalized === 'WAITING_FOR_DEPENDENCY'
) {
return 'WAITING';
diff --git a/src/app/services/dialogs/bias-rerun-dialog.spec.ts b/src/app/services/dialogs/bias-rerun-dialog.spec.ts
index 232990d..f26db86 100644
--- a/src/app/services/dialogs/bias-rerun-dialog.spec.ts
+++ b/src/app/services/dialogs/bias-rerun-dialog.spec.ts
@@ -1,5 +1,19 @@
import { TestBed } from '@angular/core/testing';
-import { BiasRerunDialogService } from './bias-rerun-dialog';
+import {
+ BiasRerunDialogService,
+ buildBiasRerunActivations,
+ hasActivatableSubflowBiasProbe
+} from './bias-rerun-dialog';
+
+const capabilities = {
+ blockType: 'LLM',
+ supported: true,
+ isolatedExperimentSupported: true,
+ fullFlowExperimentSupported: true,
+ externalSideEffects: false,
+ configurationDependent: false,
+ activationModes: []
+};
describe('BiasRerunDialogService', () => {
it('keeps a dialog state only when there are eligible block candidates', () => {
@@ -12,9 +26,74 @@ describe('BiasRerunDialogService', () => {
...base,
candidates: [{
nodeId: 'node-1', nodeName: 'Node 1', annotations: [],
- capabilities: { blockType: 'LLM', supported: true, isolatedExperimentSupported: true, fullFlowExperimentSupported: true, externalSideEffects: false, configurationDependent: false, activationModes: [] }
+ capabilities,
+ activationKind: 'ANNOTATIONS'
}]
});
expect(service.state()?.executionId).toBe('baseline');
});
+
+ it('detects probes in both the main and guard subflows', () => {
+ const block = (id: string, withProbe: boolean) => ({
+ id,
+ name: id,
+ inputs: [],
+ outputs: [],
+ typeName: 'LLMBlock',
+ specificConfiguration: {},
+ biasAnnotations: withProbe ? [{ behavioralProbe: { activationMode: 'PROMPT_DIRECTIVE' } }] : []
+ });
+ const flow = (blocks: ReturnType[]) => ({
+ blocks,
+ containers: [],
+ connections: [],
+ dependencies: []
+ });
+ const container = {
+ id: 'loop',
+ name: 'Loop',
+ inputs: [],
+ outputs: [],
+ typeName: 'LoopContainer',
+ nodeFamily: 'container' as const,
+ specificConfiguration: {
+ subFlow: flow([block('body', false)]),
+ guardSubFlow: flow([block('guard', true)])
+ }
+ };
+
+ expect(hasActivatableSubflowBiasProbe(container)).toBe(true);
+ expect(hasActivatableSubflowBiasProbe({
+ ...container,
+ specificConfiguration: { subFlow: flow([block('body', false)]) }
+ })).toBe(false);
+ });
+
+ it('builds annotation and all-or-nothing subflow activations', () => {
+ const candidates = [
+ {
+ nodeId: 'block-1',
+ nodeName: 'Block',
+ annotations: [],
+ capabilities,
+ activationKind: 'ANNOTATIONS' as const
+ },
+ {
+ nodeId: 'container-1',
+ nodeName: 'Container',
+ annotations: [],
+ capabilities,
+ activationKind: 'SUBFLOW' as const
+ }
+ ];
+
+ expect(buildBiasRerunActivations(
+ candidates,
+ { 'block-1': ['annotation-1'], 'container-1': ['must-not-be-sent'] },
+ { 'container-1': true }
+ )).toEqual([
+ { nodeId: 'block-1', annotationIds: ['annotation-1'] },
+ { nodeId: 'container-1', annotationIds: [], includeSubflow: true }
+ ]);
+ });
});
diff --git a/src/app/services/dialogs/bias-rerun-dialog.ts b/src/app/services/dialogs/bias-rerun-dialog.ts
index 1dc9546..6f9cb64 100644
--- a/src/app/services/dialogs/bias-rerun-dialog.ts
+++ b/src/app/services/dialogs/bias-rerun-dialog.ts
@@ -1,6 +1,6 @@
import { Injectable, signal } from '@angular/core';
-import { BiasAnnotation } from '@models/flow';
-import { BiasCapabilities } from '@models/bias-impact';
+import { BiasAnnotation, FlowData, FlowNode } from '@models/flow';
+import { BiasCapabilities, BiasRerunActivation } from '@models/bias-impact';
import { TaskExecution } from '@models/task-execution';
export type BiasRerunCandidate = {
@@ -8,6 +8,7 @@ export type BiasRerunCandidate = {
nodeName: string;
annotations: BiasAnnotation[];
capabilities: BiasCapabilities;
+ activationKind: 'ANNOTATIONS' | 'SUBFLOW';
};
export type BiasRerunDialogInput = {
@@ -27,3 +28,33 @@ export class BiasRerunDialogService {
close() { this._state.set(null); }
}
+
+export function hasActivatableSubflowBiasProbe(container: FlowNode): boolean {
+ const configuration = container.specificConfiguration as Record | null | undefined;
+ const subflows = [configuration?.['subFlow'], configuration?.['guardSubFlow']]
+ .filter((value): value is FlowData => !!value && typeof value === 'object' && !Array.isArray(value));
+
+ return subflows.some((subflow) =>
+ (Array.isArray(subflow.blocks) ? subflow.blocks : []).some((block) =>
+ (Array.isArray(block.biasAnnotations) ? block.biasAnnotations : [])
+ .some((annotation) => annotation.behavioralProbe != null)
+ )
+ );
+}
+
+export function buildBiasRerunActivations(
+ candidates: BiasRerunCandidate[],
+ annotationIdsByNode: Record,
+ selectedSubflowsByNode: Record
+): BiasRerunActivation[] {
+ return candidates.flatMap((candidate) => {
+ if (candidate.activationKind === 'SUBFLOW') {
+ return selectedSubflowsByNode[candidate.nodeId]
+ ? [{ nodeId: candidate.nodeId, annotationIds: [], includeSubflow: true }]
+ : [];
+ }
+
+ const annotationIds = annotationIdsByNode[candidate.nodeId] ?? [];
+ return annotationIds.length ? [{ nodeId: candidate.nodeId, annotationIds }] : [];
+ });
+}
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 e78a007..4d5b34e 100644
--- a/src/app/services/task-executions/task-executions-call.base.ts
+++ b/src/app/services/task-executions/task-executions-call.base.ts
@@ -11,6 +11,7 @@ import { Observable } from 'rxjs';
export abstract class TaskExecutionsCallServiceBase {
abstract retrieveAllTaskExecutions(): Observable;
abstract retrieveTaskExecutionGroups(): Observable;
+ abstract retrieveTaskExecution(executionId: 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 acbbf56..e8cbac5 100644
--- a/src/app/services/task-executions/task-executions-call.fake.ts
+++ b/src/app/services/task-executions/task-executions-call.fake.ts
@@ -463,6 +463,10 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
return of(this.buildExecutionGroups());
}
+ override retrieveTaskExecution(executionId: string): Observable {
+ return of(this.cloneExecution(this.findExecution(executionId)));
+ }
+
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 6a3081a..e3ea8c7 100644
--- a/src/app/services/task-executions/task-executions-call.spec.ts
+++ b/src/app/services/task-executions/task-executions-call.spec.ts
@@ -45,6 +45,81 @@ describe('TaskExecutionsCallService bias APIs', () => {
afterEach(() => httpMock.verify());
+ it('retrieves an interactive child execution with its subflow metadata', async () => {
+ const result = firstValueFrom(service.retrieveTaskExecution('child execution'));
+ const request = httpMock.expectOne(`${environment.apiUrl}/executions/child%20execution`);
+ expect(request.request.method).toBe('GET');
+ request.flush({
+ id: 'child execution',
+ name: 'Container subflow',
+ creationTime: 1,
+ executionKind: 'SUBFLOW',
+ parentExecutionId: 'parent-1',
+ parentStepId: 'container-1',
+ parentIterationIndex: 2,
+ subflowRole: 'MAIN',
+ context: {
+ inputs: {},
+ result: {},
+ errors: {},
+ warnings: [],
+ status: 'WAITING',
+ waitingSteps: ['human-1'],
+ steps: {
+ 'human-1': {
+ id: 'human-1',
+ status: 'WAITING_FOR_INTERACTION',
+ simulated: false
+ }
+ }
+ }
+ });
+
+ await expect(result).resolves.toEqual(expect.objectContaining({
+ executionKind: 'SUBFLOW',
+ parentExecutionId: 'parent-1',
+ parentStepId: 'container-1',
+ parentIterationIndex: 2,
+ subflowRole: 'MAIN'
+ }));
+ });
+
+ it('keeps container continuation fields on waiting parent steps', async () => {
+ const result = firstValueFrom(service.retrieveTaskExecution('parent-1'));
+ const request = httpMock.expectOne(`${environment.apiUrl}/executions/parent-1`);
+ request.flush({
+ id: 'parent-1',
+ name: 'Parent',
+ creationTime: 1,
+ context: {
+ inputs: {},
+ result: {},
+ errors: {},
+ warnings: [],
+ status: 'WAITING',
+ waitingSteps: ['container-1'],
+ steps: {
+ 'container-1': {
+ id: 'container-1',
+ status: 'WAITING_FOR_SUBFLOW',
+ simulated: false,
+ activeInnerExecutionId: 'child-1',
+ containerContinuationPhase: 'WAITING_FOR_SUBFLOW',
+ containerIterationIndex: 2
+ }
+ }
+ }
+ });
+
+ const execution = await result;
+ expect(execution.context.steps['container-1']).toEqual(expect.objectContaining({
+ status: 'WAITING_FOR_SUBFLOW',
+ activeInnerExecutionId: 'child-1',
+ containerContinuationPhase: 'WAITING_FOR_SUBFLOW',
+ containerIterationIndex: 2
+ }));
+ });
+
it('maps the execution flow snapshot, branched topology, bias annotations and node capabilities', async () => {
const result = firstValueFrom(service.retrieveAllTaskExecutions());
const request = httpMock.expectOne(`${environment.apiUrl}/executions`);
@@ -208,12 +283,20 @@ describe('TaskExecutionsCallService bias APIs', () => {
it('uses the confirmed biased rerun and comparison routes', async () => {
const rerun = firstValueFrom(service.createBiasedRerun('baseline-1', {
- activations: [{ nodeId: 'node-1', annotationIds: ['annotation-1'] }],
+ activations: [
+ { nodeId: 'node-1', annotationIds: ['annotation-1'] },
+ { nodeId: 'container-1', annotationIds: [], includeSubflow: true }
+ ],
externalSideEffectPolicy: 'MOCK',
confirmExternalSideEffects: false
}));
const rerunRequest = httpMock.expectOne(`${environment.apiUrl}/executions/baseline-1/bias-rerun`);
expect(rerunRequest.request.method).toBe('POST');
+ expect(rerunRequest.request.body.activations[1]).toEqual({
+ nodeId: 'container-1',
+ annotationIds: [],
+ includeSubflow: true
+ });
rerunRequest.flush({ id: 'variant-1', name: 'Variant', creationTime: 1, context: {} });
await expect(rerun).resolves.toEqual(expect.objectContaining({ id: 'variant-1' }));
diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts
index 56a83cc..bc05b87 100644
--- a/src/app/services/task-executions/task-executions-call.ts
+++ b/src/app/services/task-executions/task-executions-call.ts
@@ -32,6 +32,12 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
);
}
+ override retrieveTaskExecution(executionId: string): Observable {
+ return this.http
+ .get(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}`)
+ .pipe(map((raw) => this.mapExecution(raw)));
+ }
+
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 30a9ebc..07b93f3 100644
--- a/src/app/services/task-executions/task-executions.spec.ts
+++ b/src/app/services/task-executions/task-executions.spec.ts
@@ -124,4 +124,65 @@ describe('TaskExecutionsService bias operations', () => {
expect(service.taskExecutions()[0].context.status).toBe('SUCCESS');
});
+
+ it('caches child interaction responses without adding them to the top-level list', async () => {
+ const child: TaskExecution = {
+ id: 'child-1',
+ name: 'Interactive subflow',
+ creationTime: 1,
+ executionKind: 'SUBFLOW',
+ parentExecutionId: 'parent-1',
+ parentStepId: 'container-1',
+ context: {
+ inputs: {},
+ result: { 'decision-1:choice': 'approve' },
+ errors: {},
+ warnings: [],
+ steps: {},
+ status: 'SUCCESS',
+ waitingSteps: []
+ }
+ };
+ (service as any)._taskExecutions.set([]);
+ vi.spyOn(service, 'refresh').mockImplementation(() => undefined);
+ service.taskExecutionsCallService = {
+ submitInteractionText: vi.fn().mockReturnValue(of(child))
+ } as unknown as typeof service.taskExecutionsCallService;
+
+ await lastValueFrom(service.submitInteractionText(
+ 'child-1',
+ 'decision-1',
+ 'choice',
+ 'approve'
+ ));
+
+ expect(service.followedExecutions()['child-1']).toEqual(child);
+ expect(service.taskExecutions()).toEqual([]);
+ });
+
+ it('retrieves and caches a child execution directly', async () => {
+ const child = {
+ id: 'child-1',
+ name: 'Interactive subflow',
+ creationTime: 1,
+ executionKind: 'SUBFLOW',
+ context: {
+ inputs: {},
+ result: {},
+ errors: {},
+ warnings: [],
+ steps: {},
+ status: 'WAITING',
+ waitingSteps: ['human-1']
+ }
+ } satisfies TaskExecution;
+ service.taskExecutionsCallService = {
+ retrieveTaskExecution: vi.fn().mockReturnValue(of(child))
+ } as unknown as typeof service.taskExecutionsCallService;
+
+ await lastValueFrom(service.retrieveExecution('child-1'));
+
+ expect(service.followedExecutions()['child-1']).toEqual(child);
+ expect(service.taskExecutions()).not.toContain(child);
+ });
});
diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts
index 6b9d2db..14db834 100644
--- a/src/app/services/task-executions/task-executions.ts
+++ b/src/app/services/task-executions/task-executions.ts
@@ -42,12 +42,14 @@ export class TaskExecutionsService {
private _pendingExecutionCreation = signal(false);
private _biasExperimentInProgress = signal(false);
private _biasRerunInProgress = signal(false);
+ private _followedExecutions = signal>({});
taskExecutions = this._taskExecutions.asReadonly();
taskExecutionGroups = this._taskExecutionGroups.asReadonly();
pendingExecutionCreation = this._pendingExecutionCreation.asReadonly();
biasExperimentInProgress = this._biasExperimentInProgress.asReadonly();
biasRerunInProgress = this._biasRerunInProgress.asReadonly();
+ followedExecutions = this._followedExecutions.asReadonly();
init() {
if (this.initialized) return;
@@ -80,6 +82,16 @@ export class TaskExecutionsService {
);
}
+ retrieveExecution(executionId: string) {
+ return this.withRefreshAndErrorHandling(
+ this.taskExecutionsCallService.retrieveTaskExecution(executionId).pipe(
+ tap((execution) => this.cacheFollowedExecution(execution))
+ ),
+ 'Retrieve execution failed',
+ false
+ );
+ }
+
createExecution(flowId: string) {
this._pendingExecutionCreation.set(true);
return this.taskExecutionsCallService.createTaskExecution(flowId).pipe(
@@ -297,7 +309,13 @@ export class TaskExecutionsService {
return this.withRefreshAndErrorHandling(
this.taskExecutionsCallService.submitInteractionText(executionId, nodeId, fieldName, value).pipe(
- tap((updatedExecution) => this.replaceExecution(updatedExecution))
+ tap((updatedExecution) => {
+ if (updatedExecution.executionKind === 'SUBFLOW') {
+ this.cacheFollowedExecution(updatedExecution);
+ } else {
+ this.replaceExecution(updatedExecution);
+ }
+ })
),
'Submit interaction text failed'
);
@@ -348,6 +366,13 @@ export class TaskExecutionsService {
);
}
+ private cacheFollowedExecution(execution: TaskExecution) {
+ this._followedExecutions.update((current) => ({
+ ...current,
+ [execution.id]: execution
+ }));
+ }
+
private flattenGroups(groups: TaskExecutionGroup[]): TaskExecution[] {
return groups
.flatMap((group) => group.executions ?? [])
diff --git a/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.css b/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.css
index 8455e8d..17f9652 100644
--- a/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.css
+++ b/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.css
@@ -1,4 +1,5 @@
fieldset { display: grid; gap: .5rem; }
label { color: #334155; font-size: .87rem; }
+.bias-rerun-dialog__hint { color: #64748b; font-size: .8rem; margin: 0 0 0 1.4rem; }
.bias-rerun-dialog__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; padding: .6rem .7rem; }
footer { align-items: center; border-top: 1px solid #e2e8f0; display: flex; gap: 1rem; justify-content: flex-end; padding: 1rem 1.25rem; }
diff --git a/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.html b/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.html
index 8ab528d..09ff644 100644
--- a/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.html
+++ b/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.html
@@ -1,14 +1,22 @@
@if (state(); as currentState) {
@for (candidate of currentState.candidates; track candidate.nodeId) {
}
diff --git a/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.ts b/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.ts
index 22e78ac..ca9a24f 100644
--- a/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.ts
+++ b/src/app/shared/bias-rerun-dialog/bias-rerun-dialog.ts
@@ -3,7 +3,10 @@ import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { ExternalSideEffectPolicy } from '@models/bias-impact';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
-import { BiasRerunDialogService } from '@services/dialogs/bias-rerun-dialog';
+import {
+ BiasRerunDialogService,
+ buildBiasRerunActivations
+} from '@services/dialogs/bias-rerun-dialog';
import { extractBiasErrorMessage } from '@services/bias/bias-error.util';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { SideEffectPolicySelectorComponent } from '@shared/side-effect-policy-selector/side-effect-policy-selector';
@@ -23,6 +26,7 @@ export class BiasRerunDialogHostComponent {
private readonly confirmation = inject(ConfirmDialogService);
readonly state = this.dialog.state;
readonly selectedAnnotationIdsByNode = signal>({});
+ readonly selectedSubflowsByNode = signal>({});
readonly policy = signal('BLOCK');
readonly creating = signal(false);
readonly inlineError = signal(null);
@@ -31,6 +35,11 @@ export class BiasRerunDialogHostComponent {
effect(() => {
const state = this.state();
this.selectedAnnotationIdsByNode.set(Object.fromEntries((state?.candidates ?? []).map((candidate) => [candidate.nodeId, []])));
+ this.selectedSubflowsByNode.set(Object.fromEntries(
+ (state?.candidates ?? [])
+ .filter((candidate) => candidate.activationKind === 'SUBFLOW')
+ .map((candidate) => [candidate.nodeId, false])
+ ));
this.policy.set('BLOCK');
this.creating.set(false);
this.inlineError.set(null);
@@ -47,17 +56,35 @@ export class BiasRerunDialogHostComponent {
});
}
+ subflowSelected(nodeId: string): boolean {
+ return this.selectedSubflowsByNode()[nodeId] === true;
+ }
+
+ toggleSubflow(nodeId: string, checked: boolean) {
+ this.selectedSubflowsByNode.update((current) => ({ ...current, [nodeId]: checked }));
+ }
+
hasExternalSideEffects(): boolean {
- return (this.state()?.candidates ?? []).some((candidate) => this.selectedIds(candidate.nodeId).length > 0 && candidate.capabilities.externalSideEffects);
+ return (this.state()?.candidates ?? []).some((candidate) => {
+ const selected = candidate.activationKind === 'SUBFLOW'
+ ? this.subflowSelected(candidate.nodeId)
+ : this.selectedIds(candidate.nodeId).length > 0;
+ return selected && candidate.capabilities.externalSideEffects;
+ });
}
async submit() {
const state = this.state();
if (!state || this.creating()) return;
- const activations = Object.entries(this.selectedAnnotationIdsByNode())
- .filter(([, annotationIds]) => annotationIds.length > 0)
- .map(([nodeId, annotationIds]) => ({ nodeId, annotationIds }));
- if (!activations.length) { this.inlineError.set('Select at least one executable annotation.'); return; }
+ const activations = buildBiasRerunActivations(
+ state.candidates,
+ this.selectedAnnotationIdsByNode(),
+ this.selectedSubflowsByNode()
+ );
+ if (!activations.length) {
+ this.inlineError.set('Select at least one executable annotation or container subflow.');
+ return;
+ }
let confirmExternalSideEffects = false;
if (this.policy() === 'REQUIRE_CONFIRMATION') {
diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts
index bc747e3..77053c2 100644
--- a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts
+++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts
@@ -1,5 +1,6 @@
import { TaskExecutionStep } from '@models/task-execution';
import {
+ buildVisibleExecutionLogs,
getExecutionInputValues,
getExecutionOutputValues
} from './execution-viewer.utils';
@@ -31,4 +32,28 @@ describe('execution viewer runtime values', () => {
'decision-1:approve': 'Candidate evidence'
})).toEqual({ approve: 'Candidate evidence' });
});
+
+ it('surfaces inner subflow identifiers from bias experiment events', () => {
+ const [event] = buildVisibleExecutionLogs([{
+ id: 'event-1',
+ timestamp: 1,
+ type: 'BIAS_EXPERIMENT_APPLIED',
+ message: 'Applied behavioral probe',
+ details: {
+ innerExecutionId: 'inner-execution-1',
+ innerNodeId: 'inner-node-1',
+ innerStepId: 'inner-step-1',
+ iterationIndex: 2,
+ containerType: 'LoopContainer'
+ }
+ }]);
+
+ expect(event).toEqual(expect.objectContaining({
+ innerExecutionId: 'inner-execution-1',
+ innerNodeId: 'inner-node-1',
+ innerStepId: 'inner-step-1',
+ iterationIndex: '2',
+ containerType: 'LoopContainer'
+ }));
+ });
});
diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts
index 0a6e4c8..1437b68 100644
--- a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts
+++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts
@@ -51,6 +51,11 @@ export type ExecutionIntermediateInputGroup = {
export type ExecutionLogEntryView = ExecutionEventLogEntry & {
messageText: string;
levelText: string;
+ innerExecutionId: string | null;
+ innerNodeId: string | null;
+ innerStepId: string | null;
+ iterationIndex: string | null;
+ containerType: string | null;
};
const OUTPUT_PREVIEW_LIMIT = 80;
@@ -225,11 +230,26 @@ export function buildExecutionIntermediateInputGroups(
export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): ExecutionLogEntryView[] {
return [...logs]
.sort((a, b) => a.timestamp - b.timestamp)
- .map((entry) => ({
- ...entry,
- messageText: String(entry.message ?? '').trim() || fallbackExecutionLogMessage(entry),
- levelText: String(entry.level ?? 'INFO').toUpperCase(),
- }));
+ .map((entry) => {
+ const details = entry.details && typeof entry.details === 'object' && !Array.isArray(entry.details)
+ ? entry.details as Record
+ : {};
+ return {
+ ...entry,
+ messageText: String(entry.message ?? '').trim() || fallbackExecutionLogMessage(entry),
+ levelText: String(entry.level ?? 'INFO').toUpperCase(),
+ innerExecutionId: nonEmptyLogDetail(details['innerExecutionId']),
+ innerNodeId: nonEmptyLogDetail(details['innerNodeId']),
+ innerStepId: nonEmptyLogDetail(details['innerStepId']),
+ iterationIndex: nonEmptyLogDetail(details['iterationIndex']),
+ containerType: nonEmptyLogDetail(details['containerType'])
+ };
+ });
+}
+
+function nonEmptyLogDetail(value: unknown): string | null {
+ const normalized = value == null ? '' : String(value).trim();
+ return normalized || null;
}
export function isInputSet(value: unknown, multiple = false): boolean {
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.css b/src/app/shared/task-execution-viewer/task-execution-viewer.css
index 10142d6..1bfadca 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.css
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.css
@@ -420,6 +420,15 @@
gap: .35rem .75rem;
margin-top: .45rem;
}
+.execution-subflow-context {
+ align-items: center;
+ color: #075985;
+ display: flex;
+ flex-wrap: wrap;
+ font-size: .78rem;
+ gap: .35rem .75rem;
+ margin-top: .45rem;
+}
.execution-graph-action-button-bias { background: #f3e8ff; color: #7e22ce; }
.execution-graph-action-button-compare { background: #ccfbf1; color: #0f766e; }
.execution-graph-action-button-compare:hover:not(:disabled) { filter: brightness(1.05); }
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html
index 02cafb3..08b80ed 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.html
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html
@@ -6,6 +6,19 @@
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
index 3d872e6..c31787d 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
@@ -38,7 +38,11 @@ import { TaskExecutionsService } from '@services/task-executions/task-executions
import { FlowsService } from '@services/flows/flows';
import { ContainersService } from '@services/containers/containers';
import { BlocksService } from '@services/blocks/blocks';
-import { BiasRerunDialogService, BiasRerunCandidate } from '@services/dialogs/bias-rerun-dialog';
+import {
+ BiasRerunDialogService,
+ BiasRerunCandidate,
+ hasActivatableSubflowBiasProbe
+} from '@services/dialogs/bias-rerun-dialog';
import { BiasCompareDialogService } from '@services/dialogs/bias-compare-dialog';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { BiasImpactReportListComponent } from '@shared/bias-impact-report-list/bias-impact-report-list';
@@ -103,6 +107,8 @@ export class TaskExecutionViewerComponent implements OnDestroy {
private static readonly SIMULATOR_PROVIDER_RETRIEVER_URL = '/retriever/LLM/providers';
private static readonly SIMULATOR_MODEL_RETRIEVER_URL = '/retriever/LLM/models';
readonly execution = input(null);
+ readonly parentExecution = input(null);
+ readonly parentContainerStep = input(null);
readonly contextAsideOpen = signal(true);
readonly activeAsideTab = signal<'inputs' | 'intermediate' | 'logs' | 'output' | 'bias-reports'>('inputs');
readonly startInProgress = signal(false);
@@ -493,11 +499,16 @@ export class TaskExecutionViewerComponent implements OnDestroy {
);
readonly canCancelExecution = computed(() => {
- const status = String(this.execution()?.context.status ?? '').toUpperCase();
+ const target = this.isSubflowExecution() ? this.parentExecution() : this.execution();
+ const status = String(target?.context.status ?? '').toUpperCase();
return !this.cancelInProgress() && (status === 'RUNNING' || status === 'WAITING');
});
+ readonly cancelExecutionTooltip = computed(() =>
+ this.isSubflowExecution() ? 'Cancel parent execution' : 'Cancel execution'
+ );
readonly canResumeExecution = computed(() => {
+ if (this.isSubflowExecution()) return false;
const status = String(this.execution()?.context.status ?? '').toUpperCase();
return !this.resumeInProgress() && status === 'SUSPENDED';
});
@@ -505,6 +516,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly canStartExecution = computed(() => {
const execution = this.execution();
if (!execution) return false;
+ if (this.isSubflowExecution()) return false;
if ((execution.missingAuthorizationKeys?.length ?? 0) > 0) return false;
const statusGroup = getExecutionStatusGroup(execution.context.status);
@@ -545,19 +557,32 @@ export class TaskExecutionViewerComponent implements OnDestroy {
});
readonly canSimulateExecution = computed(() => {
- return this.execution()?.simulationAvailable === true && this.canStartExecution() && !this.simulateInProgress();
+ return !this.isSubflowExecution()
+ && this.execution()?.simulationAvailable === true
+ && this.canStartExecution()
+ && !this.simulateInProgress();
});
+ readonly isSubflowExecution = computed(() => this.execution()?.executionKind === 'SUBFLOW');
readonly isSimulatedExecution = computed(() => this.execution()?.interactionSimulationEnabled === true);
readonly isBiasVariant = computed(() => !!this.execution()?.biasExecutionContext);
readonly canCreateBiasedRerun = computed(() =>
- getExecutionStatusGroup(this.execution()?.context.status) === 'FINAL' && !this.biasRerunOpening()
+ !this.isSubflowExecution()
+ && getExecutionStatusGroup(this.execution()?.context.status) === 'FINAL'
+ && !this.biasRerunOpening()
);
readonly canCompareBiasExecution = computed(() =>
- this.isBiasVariant()
+ !this.isSubflowExecution()
+ && this.isBiasVariant()
&& !!this.execution()?.rerunOfExecutionId
&& getExecutionStatusGroup(this.execution()?.context.status) === 'FINAL'
);
+ readonly subflowIterationIndex = computed(() => {
+ const executionIndex = this.execution()?.parentIterationIndex;
+ if (typeof executionIndex === 'number') return executionIndex;
+ const stepIndex = this.parentContainerStep()?.containerIterationIndex;
+ return typeof stepIndex === 'number' ? stepIndex : null;
+ });
readonly simulationDescriptorLabel = computed(() => {
const descriptor = this.execution()?.interactionSimulationDescriptor;
if (!descriptor) return null;
@@ -714,7 +739,9 @@ export class TaskExecutionViewerComponent implements OnDestroy {
}
cancelExecution() {
- const executionId = this.execution()?.id;
+ const executionId = this.isSubflowExecution()
+ ? this.parentExecution()?.id
+ : this.execution()?.id;
if (!executionId || !this.canCancelExecution()) return;
this.cancelInProgress.set(true);
@@ -1155,8 +1182,18 @@ export class TaskExecutionViewerComponent implements OnDestroy {
private async biasRerunCandidates(): Promise {
const candidates = this.stepsArray().flatMap((step): Array<{ nodeId: string; nodeName: string; node: FlowNode }> => {
- const node = getTaskExecutionStepNode(step);
+ const node = mergeExecutionStepNode(
+ step,
+ this.execution()?.flowSnapshot ?? this.sourceFlowData()
+ );
if (!node) return [];
+
+ if (this.isContainerExecutionNode(node)) {
+ return hasActivatableSubflowBiasProbe(node)
+ ? [{ nodeId: step.id, nodeName: node.name || step.id, node: { ...node, nodeFamily: 'container' } }]
+ : [];
+ }
+
const annotations = (node.biasAnnotations ?? []).filter((annotation) => isProbeExecutable(annotation.behavioralProbe));
return annotations.length ? [{ nodeId: step.id, nodeName: node.name || step.id, node: { ...node, biasAnnotations: annotations } }] : [];
});
@@ -1171,8 +1208,9 @@ export class TaskExecutionViewerComponent implements OnDestroy {
return {
nodeId: candidate.nodeId,
nodeName: candidate.nodeName,
- annotations: candidate.node.biasAnnotations ?? [],
- capabilities
+ annotations: candidate.node.nodeFamily === 'container' ? [] : candidate.node.biasAnnotations ?? [],
+ capabilities,
+ activationKind: candidate.node.nodeFamily === 'container' ? 'SUBFLOW' : 'ANNOTATIONS'
} satisfies BiasRerunCandidate;
}));
return resolved.filter((candidate): candidate is BiasRerunCandidate => candidate !== null);