feat: support interactive container subflows
This commit is contained in:
parent
4886bf828a
commit
8faee0fbaf
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }] : [];
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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' }));
|
||||
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? [])
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue