feat: support interactive container subflows

This commit is contained in:
Lucio Lelii 2026-07-24 07:22:38 +02:00
parent 4886bf828a
commit 8faee0fbaf
23 changed files with 795 additions and 36 deletions

View File

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

View File

@ -18,7 +18,39 @@
</div>
</div>
}
<app-task-execution-viewer [execution]="selectedExecution()"></app-task-execution-viewer>
@if (interactiveSubflowTargets().length > 1) {
<div class="interactive-subflow-switcher">
<span>Interactive containers waiting:</span>
@for (target of interactiveSubflowTargets(); track target.childExecutionId) {
<button
type="button"
[class.interactive-subflow-switcher-active]="target.childExecutionId === selectedChildExecutionId()"
(click)="selectInteractiveSubflow(target.childExecutionId)">
{{ target.containerName }}
@if (target.iterationIndex !== null) {
· iteration {{ target.iterationIndex }}
}
</button>
}
</div>
}
@if (childExecutionLoading()) {
<div class="tasks-executor-loader">
<div class="tasks-executor-loader-card">
<span class="tasks-executor-spinner" aria-hidden="true"></span>
<div class="tasks-executor-loader-title">Opening interactive subflow…</div>
<div class="tasks-executor-loader-text">Loading the active child execution.</div>
</div>
</div>
}
@if (childExecutionError(); as childError) {
<div class="interactive-subflow-error">{{ childError }}</div>
}
<app-task-execution-viewer
[execution]="displayedExecution()"
[parentExecution]="childExecution() ? selectedExecution() : null"
[parentContainerStep]="childExecution() ? activeSubflowTarget()?.parentStep ?? null : null">
</app-task-execution-viewer>
</mat-card>
</div>
</div>

View File

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

View File

@ -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<InteractiveSubflowTarget[]>(() =>
findInteractiveSubflowTargets(this.selectedExecution())
);
readonly selectedChildExecutionId = signal<string | null>(null);
readonly childExecutionLoading = signal(false);
readonly childExecutionError = signal<string | null>(null);
readonly activeSubflowTarget = computed<InteractiveSubflowTarget | null>(() => {
const selectedId = this.selectedChildExecutionId();
return this.interactiveSubflowTargets()
.find((target) => target.childExecutionId === selectedId)
?? null;
});
readonly childExecution = computed<TaskExecution | null>(() => {
const childId = this.activeSubflowTarget()?.childExecutionId;
return childId ? this.taskExecutionsService.followedExecutions()[childId] ?? null : null;
});
readonly displayedExecution = computed<TaskExecution | null>(() =>
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;

View File

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

View File

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

View File

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

View File

@ -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<typeof block>[]) => ({
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 }
]);
});
});

View File

@ -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<string, unknown> | 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<string, string[]>,
selectedSubflowsByNode: Record<string, boolean>
): 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 }] : [];
});
}

View File

@ -11,6 +11,7 @@ import { Observable } from 'rxjs';
export abstract class TaskExecutionsCallServiceBase {
abstract retrieveAllTaskExecutions(): Observable<TaskExecution[]>;
abstract retrieveTaskExecutionGroups(): Observable<TaskExecutionGroup[]>;
abstract retrieveTaskExecution(executionId: string): Observable<TaskExecution>;
abstract retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]>;
abstract createTaskExecution(flowId: string): Observable<TaskExecution>;
abstract rerunTaskExecution(executionId: string): Observable<TaskExecution>;

View File

@ -463,6 +463,10 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
return of(this.buildExecutionGroups());
}
override retrieveTaskExecution(executionId: string): Observable<TaskExecution> {
return of(this.cloneExecution(this.findExecution(executionId)));
}
override retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]> {
const execution = this.findExecution(executionId);
return of(this.buildExecutionEvents(execution));

View File

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

View File

@ -32,6 +32,12 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
);
}
override retrieveTaskExecution(executionId: string): Observable<TaskExecution> {
return this.http
.get<unknown>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}`)
.pipe(map((raw) => this.mapExecution(raw)));
}
override retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]> {
return this.http.get<ExecutionEventLogEntry[]>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/events`);
}

View File

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

View File

@ -42,12 +42,14 @@ export class TaskExecutionsService {
private _pendingExecutionCreation = signal(false);
private _biasExperimentInProgress = signal(false);
private _biasRerunInProgress = signal(false);
private _followedExecutions = signal<Record<string, TaskExecution>>({});
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 ?? [])

View File

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

View File

@ -1,14 +1,22 @@
@if (state(); as currentState) {
<app-modal-shell
title="Create biased rerun"
subtitle="Select the executable annotations to activate for the full flow."
subtitle="Select executable annotations or activate all biases in a container subflow."
ariaLabel="Create biased rerun"
(backdropClick)="close()"
(closeClick)="close()">
@for (candidate of currentState.candidates; track candidate.nodeId) {
<fieldset [disabled]="creating()"><legend>{{ candidate.nodeName }}</legend>
@for (annotation of candidate.annotations; track annotation.id) {
<label><input type="checkbox" [checked]="selectedIds(candidate.nodeId).includes($any(annotation.id))" (change)="toggleAnnotation(candidate.nodeId, $any(annotation.id), $any($event.target).checked)"> {{ annotation.category || 'Uncategorized' }} — {{ annotation.issue || annotation.rationale || annotation.id }}</label>
@if (candidate.activationKind === 'SUBFLOW') {
<label>
<input type="checkbox" [checked]="subflowSelected(candidate.nodeId)" (change)="toggleSubflow(candidate.nodeId, $any($event.target).checked)">
Activate biases in the subflow
</label>
<p class="bias-rerun-dialog__hint">All executable probes on nodes inside this container will be activated.</p>
} @else {
@for (annotation of candidate.annotations; track annotation.id) {
<label><input type="checkbox" [checked]="selectedIds(candidate.nodeId).includes($any(annotation.id))" (change)="toggleAnnotation(candidate.nodeId, $any(annotation.id), $any($event.target).checked)"> {{ annotation.category || 'Uncategorized' }} — {{ annotation.issue || annotation.rationale || annotation.id }}</label>
}
}
</fieldset>
}

View File

@ -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<Record<string, string[]>>({});
readonly selectedSubflowsByNode = signal<Record<string, boolean>>({});
readonly policy = signal<ExternalSideEffectPolicy>('BLOCK');
readonly creating = signal(false);
readonly inlineError = signal<string | null>(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') {

View File

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

View File

@ -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<string, unknown>
: {};
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 {

View File

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

View File

@ -6,6 +6,19 @@
<div class="execution-header-details">
<h2 class="text-base font-semibold text-slate-900">{{ execution()!.name }}</h2>
<p class="text-sm text-slate-500">Execution ID: {{ execution()!.id }}</p>
@if (isSubflowExecution()) {
<div class="execution-subflow-context">
<strong>Interactive container subflow</strong>
<span>Parent: {{ parentExecution()?.name || execution()!.parentExecutionId || 'Unknown' }}</span>
<span>Container: {{ parentContainerStep()?.node?.name || execution()!.parentStepId || 'Unknown' }}</span>
@if (subflowIterationIndex(); as iterationIndex) {
<span>Iteration {{ iterationIndex }}</span>
}
@if (execution()!.subflowRole) {
<span>Role: {{ execution()!.subflowRole }}</span>
}
</div>
}
@if (isSimulatedExecution()) {
<p class="text-sm text-sky-700">Simulated interactive execution</p>
@if (simulationDescriptorLabel(); as simulationDescriptor) {
@ -74,8 +87,8 @@
<button
type="button"
class="execution-action-button execution-graph-action-button execution-action-button-stop"
matTooltip="Cancel execution"
aria-label="Cancel execution"
[matTooltip]="cancelExecutionTooltip()"
[attr.aria-label]="cancelExecutionTooltip()"
[disabled]="!canCancelExecution()"
(click)="cancelExecution()">
<mat-icon fontIcon="stop"></mat-icon>
@ -268,6 +281,21 @@
@if (event.type) {
<span>{{ event.type }}</span>
}
@if (event.innerNodeId) {
<span>Inner node: {{ event.innerNodeId }}</span>
}
@if (event.innerStepId) {
<span>Inner step: {{ event.innerStepId }}</span>
}
@if (event.innerExecutionId) {
<span>Inner execution: {{ event.innerExecutionId }}</span>
}
@if (event.iterationIndex) {
<span>Iteration: {{ event.iterationIndex }}</span>
}
@if (event.containerType) {
<span>Container: {{ event.containerType }}</span>
}
</div>
</div>
</div>

View File

@ -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<TaskExecution | null>(null);
readonly parentExecution = input<TaskExecution | null>(null);
readonly parentContainerStep = input<TaskExecutionStep | null>(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<number | null>(() => {
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<BiasRerunCandidate[]> {
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);