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) {
{{ candidate.nodeName }} - @for (annotation of candidate.annotations; track annotation.id) { - + @if (candidate.activationKind === 'SUBFLOW') { + +

All executable probes on nodes inside this container will be activated.

+ } @else { + @for (annotation of candidate.annotations; track annotation.id) { + + } }
} 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 @@

{{ execution()!.name }}

Execution ID: {{ execution()!.id }}

+ @if (isSubflowExecution()) { +
+ Interactive container subflow + Parent: {{ parentExecution()?.name || execution()!.parentExecutionId || 'Unknown' }} + Container: {{ parentContainerStep()?.node?.name || execution()!.parentStepId || 'Unknown' }} + @if (subflowIterationIndex(); as iterationIndex) { + Iteration {{ iterationIndex }} + } + @if (execution()!.subflowRole) { + Role: {{ execution()!.subflowRole }} + } +
+ } @if (isSimulatedExecution()) {

Simulated interactive execution

@if (simulationDescriptorLabel(); as simulationDescriptor) { @@ -74,8 +87,8 @@
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);