humainflow-web/src/app/shared/task-execution-viewer/task-execution-viewer.ts

1633 lines
62 KiB
TypeScript

import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, HostListener, inject, input, OnDestroy, signal, viewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import {
areFlowValueKindsCompatible,
FlowBlock,
FlowBlockConnection,
FlowContainer,
FlowData,
LLMDescriptor,
FlowNode,
FlowNodeDependency, isProbeExecutable,
normalizeFlowPortValueKinds
} from '@models/flow';
import {
ExecutionEventLogEntry,
getTaskExecutionStepNode,
getExecutionStatusGroup,
TaskExecution,
TaskExecutionAuthorizationRequirement,
TaskExecutionStep
} from '@models/task-execution';
import { activeAnnotationIdsFor, biasInterventionMix, isBiasVariantContext } from '@models/bias-impact';
import { ExecutionVaultCredential, LlmProviderCapability } from '@models/llm-provider';
import { VaultSecret } from '@models/assistant';
import {
EditableExecutionInput,
TaskExecutionInputsPanelComponent
} from '@shared/task-execution-inputs-panel/task-execution-inputs-panel';
import { ReteEditor } from '@shared/rete-editor/rete-editor';
import {
HumanInteractionChatMessage,
HumanInteractionDialogService
} from '@services/dialogs/human-interaction-dialog';
import { NodeSettingField, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { FieldRetriever } from '@services/retriever/field-retriever';
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 { LlmProviderService } from '@services/llm-provider/llm-provider';
import { ExecutionVaultCredentialsService } from '@services/llm-provider/execution-vault-credentials';
import { VaultService } from '@services/vault/vault';
import { extractHttpErrorMessage } from '@services/shared/http-error.util';
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';
import { JsonViewerComponent } from '@shared/json-viewer/json-viewer';
import { catchError, concat, firstValueFrom, Observable, of, take, tap } from 'rxjs';
import {
ExecutionOutputEntry,
ExecutionOutputGroup,
ExecutionIntermediateInputEntry,
ExecutionIntermediateInputGroup,
ExecutionLogEntryView,
stepTitle,
stepNodeId,
stringifyOutputValue,
formatDuration,
fallbackExecutionLogMessage,
logLevelClass as _logLevelClass,
logTypeIcon as _logTypeIcon,
formatExecutionOutputLabel,
buildExecutionOutputs,
buildExecutionOutputGroups,
buildExecutionIntermediateInputs,
buildExecutionIntermediateInputGroups,
buildVisibleExecutionLogs,
normalizeEditableInputValue,
planInputSaves,
preparedInputValue,
getExecutionInputValues,
getExecutionOutputValues,
getConnectedInputs,
getConnectedOutputs,
getExecutionErrors,
hasStoredValue,
getExecutionWarnings,
AuthorizationGate,
VaultAuthorizationEntry,
buildAuthorizationGate,
isExecutionStartable,
} from './execution-viewer.utils';
import {
mergeExecutionStepNode,
resolveExecutionConnections,
resolveExecutionDependencies
} from './execution-graph';
@Component({
selector: 'app-task-execution-viewer',
imports: [CommonModule, FormsModule, ReteEditor, TaskExecutionInputsPanelComponent, MatButtonModule, MatIconModule, MatTooltipModule, MatFormFieldModule, MatSelectModule, BiasImpactReportListComponent, JsonViewerComponent],
templateUrl: './task-execution-viewer.html',
styleUrl: './task-execution-viewer.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskExecutionViewerComponent implements OnDestroy {
private static readonly EVENTS_POLL_INTERVAL_MS = 5000;
private taskExecutionsService = inject(TaskExecutionsService);
private flowsService = inject(FlowsService);
private humanInteractionDialog = inject(HumanInteractionDialogService);
private settingsDialog = inject(NodeSettingsDialogService);
private fieldRetriever = inject(FieldRetriever);
private containersService = inject(ContainersService);
private blocksService = inject(BlocksService);
private llmProviderService = inject(LlmProviderService);
private executionVaultCredentials = inject(ExecutionVaultCredentialsService);
private vaultService = inject(VaultService);
private biasRerunDialog = inject(BiasRerunDialogService);
private biasCompareDialog = inject(BiasCompareDialogService);
private biasComparisonViewState = inject(BiasComparisonViewStateService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private lastExecutionId: string | null = null;
private lastExecutionStatus: string | null = null;
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 isFullscreen = signal(false);
readonly activeAsideTab = signal<'inputs' | 'intermediate' | 'logs' | 'output' | 'bias-reports'>('inputs');
readonly startInProgress = signal(false);
readonly simulateInProgress = signal(false);
readonly biasRerunOpening = signal(false);
readonly cancelInProgress = signal(false);
readonly resumeInProgress = signal(false);
readonly savingInputs = signal<Record<string, boolean>>({});
readonly savingErrors = signal<Record<string, string>>({});
readonly pendingTextInputs = signal<Record<string, string | string[]>>({});
/** Edited but not yet sent, so the panel can offer one Save for the lot. */
readonly pendingInputKeys = computed(() => Object.keys(this.pendingTextInputs()));
/** Set when the single request carrying every edited global fails; cleared on the next edit. */
readonly globalSaveError = signal<string | null>(null);
readonly pendingAuthorizationValues = signal<Record<string, string>>({});
readonly savingAuthorizations = signal<Record<string, boolean>>({});
readonly authorizationErrors = signal<Record<string, string>>({});
readonly llmProviderCapabilities = signal<LlmProviderCapability[]>([]);
readonly llmProviderCapabilitiesLoading = signal(false);
readonly llmProviderCapabilitiesError = signal<string | null>(null);
readonly llmCredentialOptions = signal<Record<string, ExecutionVaultCredential[]>>({});
readonly llmCredentialLoading = signal<Record<string, boolean>>({});
readonly llmCredentialErrors = signal<Record<string, string>>({});
/** Satisfied requirements the user reopened to pick a different credential. */
readonly editingAuthorizationKeys = signal<Record<string, boolean>>({});
readonly credentialFormOpen = signal(false);
readonly credentialFormProvider = signal('');
readonly credentialFormLabel = signal('');
readonly credentialFormDescription = signal('');
readonly credentialFormValue = signal('');
readonly credentialFormSaving = signal(false);
readonly credentialFormError = signal<string | null>(null);
readonly outputPreviewModal = signal<ExecutionOutputEntry | null>(null);
readonly intermediateInputPreviewModal = signal<ExecutionIntermediateInputEntry | null>(null);
readonly executionLogs = signal<ExecutionEventLogEntry[]>([]);
readonly sourceFlowData = signal<FlowData | null>(null);
readonly sourceFlowLoading = signal(false);
readonly logsLoading = signal(false);
readonly logsError = signal<string | null>(null);
private readonly logsScrollViewport = viewChild<ElementRef<HTMLDivElement>>('logsScrollViewport');
private sourceFlowRequestVersion = 0;
private readonly sourceFlowCache = new Map<string, FlowData>();
private readonly requestedCredentialProviders = new Set<string>();
constructor() {
effect(() => {
const executionId = this.execution()?.id ?? null;
if (executionId === this.lastExecutionId) return;
this.lastExecutionId = executionId;
this.lastExecutionStatus = String(this.execution()?.context.status ?? '').toUpperCase() || null;
this.pendingAuthorizationValues.set({});
this.savingAuthorizations.set({});
this.authorizationErrors.set({});
this.llmCredentialOptions.set({});
this.llmCredentialLoading.set({});
this.llmCredentialErrors.set({});
this.editingAuthorizationKeys.set({});
this.requestedCredentialProviders.clear();
this.credentialFormOpen.set(false);
this.credentialFormError.set(null);
this.loadLlmProviderCapabilities();
this.activeAsideTab.set('inputs');
this.outputPreviewModal.set(null);
this.intermediateInputPreviewModal.set(null);
this.executionLogs.set([]);
this.logsError.set(null);
this.logsLoading.set(false);
});
// Credentials follow the gate, not the provider catalog: a requirement needs a
// vault secret whether or not the catalog could be read.
effect(() => {
for (const provider of this.authorizationGate().missingProviders) {
this.loadLlmCredentialsOnce(provider);
}
});
effect(() => {
const execution = this.execution();
const requestVersion = ++this.sourceFlowRequestVersion;
const embeddedFlow = execution?.flowSnapshot ?? null;
if (embeddedFlow) {
this.sourceFlowData.set(embeddedFlow);
this.sourceFlowLoading.set(false);
return;
}
if (Array.isArray(execution?.stepConnections)) {
this.sourceFlowData.set(null);
this.sourceFlowLoading.set(false);
return;
}
const flowId = String(execution?.sourceFlowId ?? execution?.flowId ?? '').trim();
if (!flowId) {
this.sourceFlowData.set(null);
this.sourceFlowLoading.set(false);
return;
}
const cached = this.sourceFlowCache.get(flowId);
if (cached) {
this.sourceFlowData.set(cached);
this.sourceFlowLoading.set(false);
return;
}
this.sourceFlowData.set(null);
this.sourceFlowLoading.set(true);
this.flowsService.getFlowById(flowId).pipe(take(1)).subscribe({
next: (flow) => {
if (requestVersion !== this.sourceFlowRequestVersion) return;
this.sourceFlowCache.set(flowId, flow.data);
this.sourceFlowData.set(flow.data);
this.sourceFlowLoading.set(false);
},
error: () => {
if (requestVersion !== this.sourceFlowRequestVersion) return;
this.sourceFlowData.set(null);
this.sourceFlowLoading.set(false);
}
});
});
effect(() => {
if (!this.executionOutputTabEnabled() && this.activeAsideTab() === 'output') {
this.activeAsideTab.set('inputs');
}
});
effect(() => {
const status = String(this.execution()?.context.status ?? '').toUpperCase();
if (!status) return;
const becameSuccessful =
(status === 'SUCCESS' || status === 'COMPLETED') &&
this.lastExecutionStatus !== status &&
this.lastExecutionStatus !== 'SUCCESS' &&
this.lastExecutionStatus !== 'COMPLETED';
if (becameSuccessful && this.executionOutputTabEnabled()) {
this.activeAsideTab.set('output');
}
this.lastExecutionStatus = status;
});
effect((onCleanup) => {
const execution = this.execution();
const executionId = execution?.id ?? null;
if (!executionId || !execution) return;
this.fetchExecutionLogs(executionId);
const statusGroup = getExecutionStatusGroup(execution.context.status);
if (statusGroup !== 'RUNNING' && statusGroup !== 'PAUSED') return;
const timer = setInterval(() => {
this.fetchExecutionLogs(executionId);
}, TaskExecutionViewerComponent.EVENTS_POLL_INTERVAL_MS);
onCleanup(() => clearInterval(timer));
});
effect(() => {
this.executionLogs();
this.logsLoading();
queueMicrotask(() => this.scrollLogsToBottom());
});
effect(() => {
const dialogState = this.humanInteractionDialog.state();
const execution = this.execution();
if (!dialogState || !execution) return;
if (dialogState.executionId !== execution.id || !dialogState.nodeId) return;
const executionStatus = String(execution.context.status ?? '').toUpperCase();
if (executionStatus === 'CANCELLED' || executionStatus === 'SUSPENDED' || execution.interactionSimulationEnabled === true) {
this.humanInteractionDialog.close(null);
return;
}
const step = execution.context.steps?.[dialogState.nodeId];
const stepStatus = String(step?.status ?? '').toUpperCase();
if (dialogState.kind !== 'chat-session' && stepStatus !== 'WAITING_FOR_INTERACTION') {
this.humanInteractionDialog.close(null);
return;
}
const stepResult = step?.result && typeof step.result === 'object' ? step.result as Record<string, unknown> : {};
const finalResult = execution.context.result ?? {};
const partialResult = execution.context.partialResult ?? {};
const historyField = dialogState.historyField || (dialogState.kind === 'chat-session' ? 'history' : null);
const responseField = dialogState.responseField || (dialogState.kind === 'chat-session' ? 'response' : null);
const historyKey = historyField ? `${dialogState.nodeId}:${historyField}` : null;
const responseKey = responseField ? `${dialogState.nodeId}:${responseField}` : null;
const rawHistory = historyField
? partialResult[historyKey ?? '']
?? finalResult[historyKey ?? '']
?? stepResult[historyField]
: undefined;
const rawResponse = responseField
? partialResult[responseKey ?? '']
?? finalResult[responseKey ?? '']
?? stepResult[responseField]
: undefined;
const nextHistory = this.toDialogHistory(rawHistory);
const nextLatestResponse = typeof rawResponse === 'string' ? rawResponse : '';
const nextIsRunning = stepStatus === 'RUNNING';
const historyHasPendingUser = !!dialogState.pendingUserMessage
&& nextHistory.some((message) =>
message.role === 'user' && String(message.content ?? '').trim() === String(dialogState.pendingUserMessage ?? '').trim()
);
const nextPendingUserMessage = historyHasPendingUser ? null : dialogState.pendingUserMessage;
const nextAwaitingAssistantResponse = dialogState.awaitingAssistantResponse
&& nextLatestResponse.trim().length > 0
&& nextLatestResponse.trim() !== String(dialogState.assistantResponseBaseline ?? '').trim()
? false
: dialogState.awaitingAssistantResponse;
const sameHistory = JSON.stringify(dialogState.history) === JSON.stringify(nextHistory);
const sameLatestResponse = dialogState.latestResponse === nextLatestResponse;
const sameIsRunning = dialogState.isRunning === nextIsRunning;
const samePendingUserMessage = dialogState.pendingUserMessage === nextPendingUserMessage;
const sameAwaitingAssistantResponse = dialogState.awaitingAssistantResponse === nextAwaitingAssistantResponse;
if (sameHistory && sameLatestResponse && sameIsRunning && samePendingUserMessage && sameAwaitingAssistantResponse) return;
this.humanInteractionDialog.update({
history: nextHistory,
latestResponse: nextLatestResponse,
pendingUserMessage: nextPendingUserMessage,
awaitingAssistantResponse: nextAwaitingAssistantResponse,
isRunning: nextIsRunning
});
});
}
readonly stepsArray = computed(() =>
Object.values(this.execution()?.context.steps ?? {})
);
readonly authorizationGate = computed<AuthorizationGate>(() =>
buildAuthorizationGate(this.execution(), {
capabilities: this.llmProviderCapabilities(),
loading: this.llmProviderCapabilitiesLoading(),
failed: !!this.llmProviderCapabilitiesError()
})
);
/** Credentials still to choose, plus the settled ones the user reopened. */
readonly pendingVaultAuthorizations = computed<VaultAuthorizationEntry[]>(() => {
const gate = this.authorizationGate();
const editing = this.editingAuthorizationKeys();
return [
...gate.vault,
...gate.satisfiedVault.filter((entry) => editing[entry.requirement.key])
];
});
/** Credentials already accepted, kept on screen so they can be reviewed or changed. */
readonly settledVaultAuthorizations = computed<VaultAuthorizationEntry[]>(() => {
const editing = this.editingAuthorizationKeys();
return this.authorizationGate().satisfiedVault.filter((entry) => !editing[entry.requirement.key]);
});
readonly runtimeAuthorizationRequirements = computed(() => this.authorizationGate().runtime);
readonly executionFlowData = computed<FlowData>(() => {
const executionStatusGroup = getExecutionStatusGroup(this.execution()?.context.status);
const contextInputs = this.execution()?.context.inputs ?? {};
const contextResults = this.execution()?.context.result ?? {};
const contextErrors = this.execution()?.context.errors ?? {};
const contextWarnings = this.execution()?.context.warnings ?? {};
const waitingSteps = this.execution()?.context.waitingSteps ?? [];
const biasContext = this.execution()?.biasExecutionContext ?? null;
const globalInputsValue = this.execution()?.context.globalInputs ?? {};
const executionVariablesValue = this.execution()?.context.executionVariables ?? {};
const projectContextValue = this.execution()?.context.projectContext ?? {};
const executionName = this.execution()?.name ?? null;
const steps = this.stepsArray();
const execution = this.execution();
const sourceFlow = execution?.flowSnapshot ?? this.sourceFlowData();
const useSourceGraphFallback = !Array.isArray(execution?.stepConnections);
const connections = this.getExecutionConnections(steps, sourceFlow);
const dependencies = this.getExecutionDependencies(steps, sourceFlow);
const blocks: FlowBlock[] = [];
const containers: FlowContainer[] = [];
const renderedNodeIds = new Set<string>();
for (const [index, step] of steps.entries()) {
const stepNode = mergeExecutionStepNode(step, sourceFlow);
if (!stepNode) continue;
const connectedInputs = Array.from(new Set([
...getConnectedInputs(step),
...connections
.filter((connection) => connection.targetId === stepNode.id)
.map((connection) => connection.targetName)
]));
const connectedOutputs = Array.from(new Set([
...getConnectedOutputs(step),
...connections
.filter((connection) => connection.sourceId === stepNode.id)
.map((connection) => connection.sourceName)
]));
const executionNode: FlowNode = {
...stepNode,
nodeFamily: this.isContainerExecutionNode(stepNode) ? 'container' : 'block',
specificConfiguration: {
...(stepNode.specificConfiguration ?? {}),
__executionId: this.execution()?.id ?? null,
__executionNodeId: step.id,
__executionStatus: this.execution()?.context.status ?? null,
__interactionSimulationEnabled: this.execution()?.interactionSimulationEnabled === true,
__stepStatus: step.status,
// Which iteration a container is on, so a node that is visibly working can say what it
// is working on rather than only that it is.
__containerIterationIndex: typeof step.containerIterationIndex === 'number'
? step.containerIterationIndex
: null,
__stepSkipReason: step.skipReason ?? null,
__stepSimulated: step.simulated === true,
__stepUserInteractive: stepNode.userInteractive === true,
__executionStatusGroup: executionStatusGroup,
__isWaitingStep: waitingSteps.includes(step.id),
__executionInputs: getExecutionInputValues(step, contextInputs),
__connectedInputs: connectedInputs,
__executionOutputs: getExecutionOutputValues(step, contextResults),
__connectedOutputs: connectedOutputs,
__hasDependencyInputConnection: dependencies.some(
(dependency) => dependency.targetId === stepNode.id
),
__hasDependantOutputConnection: dependencies.some(
(dependency) => dependency.sourceId === stepNode.id
),
__executionErrors: getExecutionErrors(step.id, contextErrors),
__executionWarnings: getExecutionWarnings(step.id, contextWarnings),
__stepResultData: step.result ?? null,
__executionPartialResult: this.execution()?.context.partialResult ?? null,
__biasActiveAnnotationIds: activeAnnotationIdsFor(biasContext, step.id),
__globalInputs: globalInputsValue,
__executionVariables: executionVariablesValue,
__projectContext: projectContextValue,
__executionName: executionName
},
position: stepNode.position ?? {
x: 120 + (index % 3) * 340,
y: 100 + Math.floor(index / 3) * 220
}
};
renderedNodeIds.add(executionNode.id);
if (executionNode.nodeFamily === 'container') {
containers.push(executionNode);
} else {
blocks.push(executionNode);
}
}
for (const sourceNode of useSourceGraphFallback
? [...(sourceFlow?.blocks ?? []), ...(sourceFlow?.containers ?? [])]
: []) {
if (renderedNodeIds.has(sourceNode.id)) continue;
const connectedInputs = (sourceFlow?.connections ?? [])
.filter((connection) => connection.targetId === sourceNode.id)
.map((connection) => connection.targetName);
const connectedOutputs = (sourceFlow?.connections ?? [])
.filter((connection) => connection.sourceId === sourceNode.id)
.map((connection) => connection.sourceName);
const executionNode: FlowNode = {
...sourceNode,
specificConfiguration: {
...(sourceNode.specificConfiguration ?? {}),
__executionId: this.execution()?.id ?? null,
__executionNodeId: sourceNode.id,
__executionStatus: this.execution()?.context.status ?? null,
__executionStatusGroup: executionStatusGroup,
__stepStatus: 'SKIPPED',
__isWaitingStep: false,
__executionInputs: {},
__connectedInputs: connectedInputs,
__executionOutputs: {},
__connectedOutputs: connectedOutputs,
__hasDependencyInputConnection: this.hasIncomingDependency(sourceNode.id),
__hasDependantOutputConnection: this.hasOutgoingDependency(sourceNode.id),
__executionErrors: [],
__executionWarnings: [],
__stepResultData: null,
__executionPartialResult: this.execution()?.context.partialResult ?? null,
__biasActiveAnnotationIds: activeAnnotationIdsFor(biasContext, sourceNode.id),
__globalInputs: globalInputsValue,
__executionVariables: executionVariablesValue,
__projectContext: projectContextValue,
__executionName: executionName
}
};
if (executionNode.nodeFamily === 'container') {
containers.push(executionNode);
} else {
blocks.push(executionNode);
}
}
return {
blocks,
containers,
connections,
dependencies,
globalInputs: sourceFlow?.globalInputs ?? [],
lanes: sourceFlow?.lanes ?? []
};
});
/**
* Where a flow's answer actually lands when its last block is wired into an End node: the flow
* result is built only from unconnected outputs, so that connection moves the value here. Without
* surfacing it the run looks like it produced nothing.
*/
readonly outcomes = computed(() => this.execution()?.context.outcomes ?? []);
/**
* Open by default: this is the flow's answer, and it was invisible until now. Collapsing gives
* the graph its height back when the payload is a long document.
*/
readonly outcomesOpen = signal(true);
/** Ids, simulator descriptors and probe internals are diagnostics: useful, but not the headline. */
readonly headerDetailsOpen = signal(false);
readonly biasVariantLabel = computed(() => {
switch (biasInterventionMix(this.execution()?.biasExecutionContext)) {
case 'MIXED': return 'Bias + mitigation';
case 'MITIGATION': return 'Mitigation variant';
case 'BIAS': return 'Bias variant';
// A variant with nothing recorded as active: say so rather than pick one of the two.
default: return 'Bias variant (unspecified)';
}
});
toggleHeaderDetails() {
this.headerDetailsOpen.update((open) => !open);
}
/** Kept in the header so the conclusion is still readable while collapsed. */
readonly outcomeCodes = computed(() => this.outcomes().map((outcome) => outcome.code).join(', '));
toggleOutcomes() {
this.outcomesOpen.update((open) => !open);
}
readonly hasOutcomePayload = computed(() =>
this.outcomes().some((outcome) => outcome.payload !== null && outcome.payload !== undefined));
/**
* A text payload is shown as text, not through the JSON tree: the common case is a generated
* document - an email, a report - and the tree would quote it and collapse its line breaks.
*/
isTextPayload(payload: unknown): payload is string {
return typeof payload === 'string';
}
stepNameForOutcome(stepId: string): string {
return this.execution()?.context.steps?.[stepId]?.node?.name ?? stepId;
}
readonly formattedDuration = computed(() => {
const context = this.execution()?.context;
if (!context?.startTime || !context?.endTime) return '-';
return formatDuration(context.startTime, context.endTime);
});
readonly executionOutputTabEnabled = computed(() => {
const status = String(this.execution()?.context.status ?? '').toUpperCase();
return status === 'SUCCESS' || status === 'COMPLETED';
});
readonly executionOutputs = computed<ExecutionOutputEntry[]>(() =>
buildExecutionOutputs(this.execution())
);
readonly executionOutputGroups = computed<ExecutionOutputGroup[]>(() =>
buildExecutionOutputGroups(this.executionOutputs())
);
readonly executionIntermediateInputs = computed<ExecutionIntermediateInputEntry[]>(() =>
buildExecutionIntermediateInputs(this.execution())
);
readonly executionIntermediateInputGroups = computed<ExecutionIntermediateInputGroup[]>(() =>
buildExecutionIntermediateInputGroups(this.executionIntermediateInputs())
);
readonly inputsReadOnly = computed(() => {
const status = this.execution()?.context.status;
return getExecutionStatusGroup(status) !== 'INIT';
});
readonly visibleExecutionLogs = computed<ExecutionLogEntryView[]>(() =>
buildVisibleExecutionLogs(this.executionLogs())
);
readonly canCancelExecution = computed(() => {
const target = this.isSubflowExecution() ? this.parentExecution() : this.execution();
const status = String(target?.context.status ?? '').toUpperCase();
return !this.cancelInProgress() && (status === 'RUNNING' || status === 'WAITING');
});
readonly startExecutionTooltip = computed(() => {
const gate = this.authorizationGate();
if (gate.missingProviders.length) return `Missing provider credential: ${gate.missingProviders.join(', ')}`;
if (gate.runtime.length) return 'Missing provider authorization';
return 'Start execution';
});
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';
});
readonly canStartExecution = computed(() =>
!this.isSubflowExecution() && isExecutionStartable(this.execution(), this.authorizationGate())
);
readonly canSimulateExecution = computed(() => {
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);
/**
* The backend sets a bias context on *every* execution, defaulting to NORMAL, so the presence of
* the object says nothing - only the mode does. Testing for presence marked every run a bias
* variant, and also offered the bias comparison on any plain rerun.
*/
readonly isBiasVariant = computed(() => isBiasVariantContext(this.execution()?.biasExecutionContext));
readonly canCreateBiasedRerun = computed(() =>
!this.isSubflowExecution()
&& getExecutionStatusGroup(this.execution()?.context.status) === 'FINAL'
&& !this.biasRerunOpening()
);
readonly canCompareBiasExecution = computed(() =>
!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;
const provider = String(descriptor.provider ?? '').trim();
const model = String(descriptor.model ?? '').trim();
if (!provider && !model) return null;
if (!provider) return model;
if (!model) return provider;
return `${provider} / ${model}`;
});
readonly editableInputs = computed<EditableExecutionInput[]>(() => {
const missingGlobalInputNames = new Set(this.execution()?.missingGlobalInputKeys ?? []);
const execution = this.execution();
if (!execution) return [];
const entries: EditableExecutionInput[] = [];
const contextInputs = execution.context.inputs ?? {};
const contextGlobalInputs = execution.context.globalInputs ?? {};
const globalInputDescriptors = execution.context.globalInputDescriptors ?? {};
for (const [descriptorKey, descriptor] of Object.entries(globalInputDescriptors)) {
const inputName = String(descriptor?.name ?? descriptorKey).trim();
if (!inputName) continue;
const key = `global:${inputName}`;
const rawValue = Object.prototype.hasOwnProperty.call(contextGlobalInputs, inputName)
? contextGlobalInputs[inputName]
: descriptor?.value;
const pendingValue = this.pendingTextInputs()[key];
entries.push({
key,
scope: 'global',
nodeId: null,
inputName,
title: 'Flow',
subtitle: inputName,
type: String(descriptor?.kind ?? 'TEXT').toUpperCase(),
multiple: Boolean(descriptor?.multiple),
// The backend's own answer, and it reports what is stored - so an unsaved edit does not
// make an input look satisfied.
provided: !missingGlobalInputNames.has(inputName),
value: pendingValue ?? normalizeEditableInputValue(rawValue, Boolean(descriptor?.multiple))
});
}
for (const step of Object.values(execution.context.steps ?? {})) {
for (const input of step.inputs ?? []) {
if (input.registered) continue;
const inputName = input.descriptor?.name;
if (!inputName) continue;
const key = `${step.id}:${inputName}`;
const rawValue = Object.prototype.hasOwnProperty.call(contextInputs, key)
? contextInputs[key]
: input.value;
const pendingValue = this.pendingTextInputs()[key];
entries.push({
key,
scope: 'node',
nodeId: step.id,
inputName,
title: stepTitle(step),
subtitle: inputName,
type: String(input.descriptor?.type ?? 'TEXT').toUpperCase(),
multiple: Boolean(input.descriptor?.multiple),
// No per-input flag from the backend here, so judge the stored value, ignoring pending.
provided: hasStoredValue(rawValue),
value: pendingValue ?? normalizeEditableInputValue(rawValue, Boolean(input.descriptor?.multiple))
});
}
}
return entries.sort((a, b) => a.title.localeCompare(b.title) || a.subtitle.localeCompare(b.subtitle));
});
/** Brings the credential picker on screen from the banner above the graph. */
openAuthorizationPanel() {
this.contextAsideOpen.set(true);
this.activeAsideTab.set('inputs');
}
toggleContextAside() {
this.contextAsideOpen.update((open) => !open);
}
toggleFullscreen() {
this.isFullscreen.update((fullscreen) => !fullscreen);
}
@HostListener('document:keydown.escape')
onEscapeKey() {
if (this.isFullscreen()) {
this.isFullscreen.set(false);
}
}
selectAsideTab(tab: 'inputs' | 'intermediate' | 'logs' | 'output' | 'bias-reports') {
if (tab === 'output' && !this.executionOutputTabEnabled()) return;
this.activeAsideTab.set(tab);
}
openOutputPreview(output: ExecutionOutputEntry, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!output.isLong) return;
this.outputPreviewModal.set(output);
}
outputPreviewTitle(output: ExecutionOutputEntry | null): string {
if (!output) return 'Output';
return output.nodeTitle;
}
outputPreviewSubtitle(output: ExecutionOutputEntry | null): string {
if (!output) return '';
return output.outputName;
}
closeOutputPreview(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.outputPreviewModal.set(null);
}
openIntermediateInputPreview(input: ExecutionIntermediateInputEntry, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!input.isLong) return;
this.intermediateInputPreviewModal.set(input);
}
intermediateInputPreviewTitle(input: ExecutionIntermediateInputEntry | null): string {
if (!input) return 'Intermediate input';
return input.nodeTitle;
}
intermediateInputPreviewSubtitle(input: ExecutionIntermediateInputEntry | null): string {
if (!input) return '';
const itemLabel = input.itemLabel ? ` · ${input.itemLabel}` : '';
return `${input.inputName}${itemLabel}`;
}
closeIntermediateInputPreview(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.intermediateInputPreviewModal.set(null);
}
startExecution() {
const executionId = this.execution()?.id;
if (!executionId || !this.canStartExecution() || this.startInProgress()) return;
this.startInProgress.set(true);
this.taskExecutionsService.startExecution(executionId).subscribe({
next: () => this.startInProgress.set(false),
error: () => this.startInProgress.set(false)
});
}
llmCredentialOptionsFor(entry: VaultAuthorizationEntry): ExecutionVaultCredential[] {
return this.llmCredentialOptions()[entry.provider.toLowerCase()] ?? [];
}
llmCredentialLoadingFor(entry: VaultAuthorizationEntry): boolean {
return this.llmCredentialLoading()[entry.provider.toLowerCase()] === true;
}
llmCredentialErrorFor(entry: VaultAuthorizationEntry): string | null {
return this.llmCredentialErrors()[entry.provider.toLowerCase()] ?? null;
}
selectedLlmCredentialFor(entry: VaultAuthorizationEntry): string {
return this.pendingAuthorizationValues()[entry.requirement.key] ?? '';
}
authorizationSavingFor(entry: VaultAuthorizationEntry): boolean {
return this.savingAuthorizations()[entry.requirement.key] === true;
}
authorizationErrorFor(entry: VaultAuthorizationEntry): string | null {
return this.authorizationErrors()[entry.requirement.key] ?? null;
}
/** Label of the credential currently answering a satisfied requirement, when it can be resolved. */
providedCredentialLabel(entry: VaultAuthorizationEntry): string {
const provided = this.execution()?.providedAuthorizations?.[entry.requirement.key];
const credentialId = typeof provided === 'string' ? provided : '';
const match = this.llmCredentialOptionsFor(entry).find((item) => item.id === credentialId);
return match?.label ?? 'Credential provided';
}
selectLlmCredential(entry: VaultAuthorizationEntry, credentialId: string) {
if (!this.llmCredentialOptionsFor(entry).some((item) => item.id === credentialId)) return;
this.applyLlmCredential(entry, credentialId);
}
changeVaultAuthorization(entry: VaultAuthorizationEntry) {
this.editingAuthorizationKeys.update((current) => ({ ...current, [entry.requirement.key]: true }));
this.reloadLlmCredentials(entry.provider);
}
cancelVaultAuthorizationChange(entry: VaultAuthorizationEntry) {
this.editingAuthorizationKeys.update((current) => {
const next = { ...current };
delete next[entry.requirement.key];
return next;
});
}
retryLlmCredentials(entry: VaultAuthorizationEntry) {
this.reloadLlmCredentials(entry.provider);
}
retryLlmProviderCapabilities() {
this.loadLlmProviderCapabilities();
}
private applyLlmCredential(entry: VaultAuthorizationEntry, credentialId: string) {
if (!credentialId) return;
this.onAuthorizationValueChange(entry.requirement, credentialId);
this.submitAuthorization(entry.requirement);
}
openVaultCredentialForm(provider: string) {
this.credentialFormError.set(null);
this.credentialFormProvider.set(provider);
this.credentialFormLabel.set('');
this.credentialFormDescription.set('');
this.credentialFormValue.set('');
this.credentialFormOpen.set(true);
}
closeVaultCredentialForm() {
this.credentialFormOpen.set(false);
this.credentialFormValue.set('');
this.credentialFormError.set(null);
}
saveExecutionCredential() {
const provider = this.credentialFormProvider().trim();
const label = this.credentialFormLabel().trim();
const value = this.credentialFormValue();
if (!provider || !label || !value.trim() || this.credentialFormSaving()) return;
this.credentialFormSaving.set(true);
this.vaultService.createSecret({
provider,
label,
description: this.credentialFormDescription().trim() || undefined,
value
}).pipe(take(1)).subscribe({
next: (credential) => {
this.credentialFormSaving.set(false);
this.credentialFormValue.set('');
this.credentialFormOpen.set(false);
this.credentialFormError.set(null);
this.reloadLlmCredentials(provider);
// The vault id is the same id the authorizations endpoint takes, so the new
// credential can answer the requirement without waiting for the listing.
if (!credential.active) return;
const entry = this.pendingVaultAuthorizations().find((item) =>
item.provider.toLowerCase() === provider.toLowerCase()
);
if (entry) this.applyLlmCredential(entry, credential.id);
},
error: (error) => {
this.credentialFormSaving.set(false);
this.credentialFormValue.set('');
this.credentialFormError.set(this.executionErrorMessage(error));
}
});
}
private loadLlmProviderCapabilities() {
if (!this.execution()?.id) return;
this.llmProviderCapabilitiesLoading.set(true);
this.llmProviderCapabilitiesError.set(null);
this.llmProviderService.listCapabilities().pipe(take(1)).subscribe({
next: (capabilities) => {
this.llmProviderCapabilities.set(capabilities);
this.llmProviderCapabilitiesLoading.set(false);
},
error: (error) => {
this.llmProviderCapabilitiesLoading.set(false);
this.llmProviderCapabilitiesError.set(this.executionErrorMessage(error));
}
});
}
private loadLlmCredentialsOnce(provider: string) {
const key = provider.trim().toLowerCase();
if (!key || this.requestedCredentialProviders.has(key)) return;
this.requestedCredentialProviders.add(key);
this.loadLlmCredentials(provider).subscribe();
}
private reloadLlmCredentials(provider: string) {
const key = provider.trim().toLowerCase();
if (!key) return;
this.requestedCredentialProviders.add(key);
this.llmCredentialErrors.update((current) => {
const next = { ...current };
delete next[key];
return next;
});
this.loadLlmCredentials(provider).subscribe();
}
private loadLlmCredentials(provider: string): Observable<ExecutionVaultCredential[]> {
const key = provider.trim().toLowerCase();
if (!key) return of([]);
this.llmCredentialLoading.update((current) => ({ ...current, [key]: true }));
return this.executionVaultCredentials.listForProvider(provider).pipe(
take(1),
tap({
next: (credentials) => {
this.llmCredentialOptions.update((current) => ({ ...current, [key]: credentials }));
this.llmCredentialLoading.update((current) => ({ ...current, [key]: false }));
},
error: (error: unknown) => {
this.llmCredentialLoading.update((current) => ({ ...current, [key]: false }));
this.llmCredentialErrors.update((current) => ({ ...current, [key]: this.executionErrorMessage(error) }));
}
})
);
}
private executionErrorMessage(error: unknown): string {
return extractHttpErrorMessage(error as any)
?? (typeof (error as { message?: unknown })?.message === 'string'
&& (error as { message: string }).message.trim()
? (error as { message: string }).message
: 'Unable to load or save the provider credential.');
}
async simulateExecution() {
const executionId = this.execution()?.id;
if (!executionId || !this.canSimulateExecution()) return;
const simulator = await this.openSimulationSettings();
if (!simulator) return;
this.simulateInProgress.set(true);
this.taskExecutionsService.simulateExecution(executionId, simulator).subscribe({
next: () => this.simulateInProgress.set(false),
error: () => this.simulateInProgress.set(false)
});
}
cancelExecution() {
const executionId = this.isSubflowExecution()
? this.parentExecution()?.id
: this.execution()?.id;
if (!executionId || !this.canCancelExecution()) return;
this.cancelInProgress.set(true);
this.taskExecutionsService.cancelExecution(executionId).subscribe({
next: () => {
this.cancelInProgress.set(false);
this.humanInteractionDialog.close(null);
},
error: () => this.cancelInProgress.set(false)
});
}
resumeExecution() {
const executionId = this.execution()?.id;
if (!executionId || !this.canResumeExecution()) return;
this.resumeInProgress.set(true);
this.taskExecutionsService.resumeExecution(executionId).subscribe({
next: () => this.resumeInProgress.set(false),
error: () => this.resumeInProgress.set(false)
});
}
async openBiasedRerunDialog() {
const execution = this.execution();
if (!execution || !this.canCreateBiasedRerun()) return;
this.biasRerunOpening.set(true);
try {
const candidates = await this.biasRerunCandidates();
if (!candidates.length) return;
this.biasRerunDialog.open({
executionId: execution.id,
candidates,
onCreated: (variant) => {
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { executionId: variant.id },
queryParamsHandling: 'merge',
replaceUrl: true
});
}
});
} finally {
this.biasRerunOpening.set(false);
}
}
openCompareDialog() {
const execution = this.execution();
const baselineExecutionId = execution?.rerunOfExecutionId;
if (!execution || !baselineExecutionId || !this.canCompareBiasExecution()) return;
this.biasCompareDialog.open({
baselineExecutionId,
biasedExecutionId: execution.id
});
}
isBiasHighlightActive(): boolean {
return this.biasComparisonViewState.activeView() !== null;
}
clearBiasHighlight() {
this.biasComparisonViewState.clear();
}
onTextInputChange(input: EditableExecutionInput, value: string | string[]) {
if (this.inputsReadOnly()) return;
this.globalSaveError.set(null);
this.pendingTextInputs.update((current) => ({ ...current, [input.key]: value }));
this.savingErrors.update((current) => {
const next = { ...current };
delete next[input.key];
return next;
});
}
/** Saves one input, over the same requests the Save bar uses - a global batch of exactly one. */
submitTextInput(input: EditableExecutionInput) {
if (this.inputsReadOnly()) return;
const executionId = this.execution()?.id;
if (!executionId) return;
const request$ = input.scope === 'global'
? this.bulkGlobalRequest([input],
{ [input.inputName]: preparedInputValue(input, this.pendingTextInputs()[input.key]) },
executionId)
: this.singleInputRequest(input, executionId);
request$.subscribe();
}
onFileInputChange(input: EditableExecutionInput, files: File[]) {
if (this.inputsReadOnly()) return;
const executionId = this.execution()?.id;
if (!executionId || !files.length) return;
this.setInputSaving(input.key, true);
const request$ = input.scope === 'global'
? (
input.multiple
? this.taskExecutionsService.prepareGlobalFileArrayInput(executionId, input.inputName, files)
: this.taskExecutionsService.prepareGlobalFileInput(executionId, input.inputName, files[0])
)
: (
input.multiple
? this.taskExecutionsService.prepareFileArrayInput(executionId, input.nodeId!, input.inputName, files)
: this.taskExecutionsService.prepareFileInput(executionId, input.nodeId!, input.inputName, files[0])
);
request$.subscribe({
next: () => this.clearInputSaving(input.key),
error: () => this.setInputError(input.key, 'Failed to upload file')
});
}
onAuthorizationValueChange(requirement: TaskExecutionAuthorizationRequirement, value: string) {
this.pendingAuthorizationValues.update((current) => ({ ...current, [requirement.key]: value }));
this.authorizationErrors.update((current) => {
const next = { ...current };
delete next[requirement.key];
return next;
});
}
submitAuthorization(requirement: TaskExecutionAuthorizationRequirement) {
const executionId = this.execution()?.id;
if (!executionId) return;
const value = (this.pendingAuthorizationValues()[requirement.key] ?? '').trim();
if (!value) return;
this.setAuthorizationSaving(requirement.key, true);
// The endpoint answers with the recomputed execution, which the service puts back
// into the store, so the gate reopens on the backend's word rather than a guess.
this.taskExecutionsService.provideAuthorization(executionId, requirement.key, value).subscribe({
next: () => {
this.pendingAuthorizationValues.update((current) => {
const next = { ...current };
delete next[requirement.key];
return next;
});
this.editingAuthorizationKeys.update((current) => {
const next = { ...current };
delete next[requirement.key];
return next;
});
this.clearAuthorizationSaving(requirement.key);
},
error: (error: unknown) => this.setAuthorizationError(
requirement.key,
extractHttpErrorMessage(error as any) ?? 'Failed to save authorization'
)
});
}
private setInputSaving(key: string, saving: boolean) {
this.savingInputs.update((current) => ({ ...current, [key]: saving }));
if (saving) {
this.savingErrors.update((current) => {
const next = { ...current };
delete next[key];
return next;
});
}
}
private clearInputSaving(key: string) {
this.savingInputs.update((current) => ({ ...current, [key]: false }));
this.savingErrors.update((current) => {
const next = { ...current };
delete next[key];
return next;
});
}
private setInputError(key: string, message: string) {
this.savingInputs.update((current) => ({ ...current, [key]: false }));
this.savingErrors.update((current) => ({ ...current, [key]: message }));
}
ngOnDestroy() {
}
private setAuthorizationSaving(key: string, saving: boolean) {
this.savingAuthorizations.update((current) => ({ ...current, [key]: saving }));
if (saving) {
this.authorizationErrors.update((current) => {
const next = { ...current };
delete next[key];
return next;
});
}
}
private clearAuthorizationSaving(key: string) {
this.savingAuthorizations.update((current) => ({ ...current, [key]: false }));
this.authorizationErrors.update((current) => {
const next = { ...current };
delete next[key];
return next;
});
}
private setAuthorizationError(key: string, message: string) {
this.savingAuthorizations.update((current) => ({ ...current, [key]: false }));
this.authorizationErrors.update((current) => ({ ...current, [key]: message }));
}
/**
* Saves every edited input.
*
* <p>The globals go in a single bulk request, and the node inputs follow one at a time. Firing a
* request per input in parallel had them all mutating the same execution at once, and the values
* the user had just typed could come back missing.
*/
submitAllTextInputs() {
if (this.inputsReadOnly()) return;
const executionId = this.execution()?.id;
if (!executionId) return;
const plan = planInputSaves(this.editableInputs(), this.pendingTextInputs());
const steps: Observable<unknown>[] = [];
if (plan.globals.length) {
steps.push(this.bulkGlobalRequest(plan.globals, plan.globalValues, executionId));
}
for (const input of plan.nodeInputs) {
steps.push(this.singleInputRequest(input, executionId));
}
if (!steps.length) return;
// Sequential: one request at a time on one execution, which is the whole point of the change.
concat(...steps).subscribe();
}
/** One request for every edited global, so they cannot overwrite one another. */
private bulkGlobalRequest(
globals: EditableExecutionInput[],
values: Record<string, string | string[]>,
executionId: string
): Observable<unknown> {
this.globalSaveError.set(null);
globals.forEach((input) => this.setInputSaving(input.key, true));
return this.taskExecutionsService.prepareGlobalInputs(executionId, values).pipe(
tap(() => globals.forEach((input) => {
this.clearPendingInput(input.key);
this.clearInputSaving(input.key);
})),
// One request, so either every global was saved or none was: one message says that, where
// the same text on each field implied as many separate problems as there were inputs.
catchError(() => {
globals.forEach((input) => this.clearInputSaving(input.key));
this.globalSaveError.set('Could not save the global inputs, so none of them were saved.');
return of(null);
})
);
}
/** A node input still goes one at a time: there is no bulk endpoint per step. */
private singleInputRequest(input: EditableExecutionInput, executionId: string): Observable<unknown> {
const value = preparedInputValue(input, this.pendingTextInputs()[input.key]);
this.setInputSaving(input.key, true);
const request$ = Array.isArray(value)
? this.taskExecutionsService.prepareStringArrayInput(executionId, input.nodeId!, input.inputName, value)
: this.taskExecutionsService.prepareStringInput(executionId, input.nodeId!, input.inputName, value);
return request$.pipe(
tap(() => {
this.clearPendingInput(input.key);
this.clearInputSaving(input.key);
}),
catchError(() => {
this.setInputError(input.key, 'Failed to update input');
return of(null);
})
);
}
private clearPendingInput(key: string) {
this.pendingTextInputs.update((current) => {
const next = { ...current };
delete next[key];
return next;
});
}
private async openSimulationSettings(): Promise<LLMDescriptor | null> {
const providerOptions = await this.loadSimulationOptions(
'providers',
{},
TaskExecutionViewerComponent.SIMULATOR_PROVIDER_RETRIEVER_URL
);
if (!providerOptions.length) {
return null;
}
const defaultProvider = providerOptions[0].value;
const initialModelOptions = await this.loadSimulationOptions(
'models',
{ provider: defaultProvider },
TaskExecutionViewerComponent.SIMULATOR_MODEL_RETRIEVER_URL
);
const buildFields = (providers: { label: string; value: string; }[], models: { label: string; value: string; }[]): NodeSettingField[] => [
{
key: 'provider',
label: 'Provider',
type: 'select',
options: providers,
required: true,
autofocus: true
},
{
key: 'model',
label: 'Model',
type: 'select',
options: models,
required: true
}
];
const result = await this.settingsDialog.open({
title: 'Simulation Settings',
fields: buildFields(providerOptions, initialModelOptions),
initial: {
provider: defaultProvider,
model: initialModelOptions[0]?.value ?? ''
},
onValuesChange: async (draft) => {
const provider = String(draft['provider'] ?? '').trim();
const modelOptions = provider
? await this.loadSimulationOptions(
'models',
{ provider },
TaskExecutionViewerComponent.SIMULATOR_MODEL_RETRIEVER_URL
)
: [];
return {
fields: buildFields(providerOptions, modelOptions),
initial: {
provider,
model: modelOptions[0]?.value ?? ''
}
};
}
});
if (!result) return null;
const provider = String(result['provider'] ?? '').trim();
const model = String(result['model'] ?? '').trim();
if (!provider || !model) {
return null;
}
return { provider, model };
}
private fetchExecutionLogs(executionId: string) {
this.logsLoading.set(true);
this.logsError.set(null);
this.taskExecutionsService.retrieveExecutionEvents(executionId).subscribe({
next: (events) => {
if (this.execution()?.id !== executionId) return;
this.executionLogs.set(Array.isArray(events) ? events : []);
this.logsLoading.set(false);
},
error: () => {
if (this.execution()?.id !== executionId) return;
this.logsError.set('Unable to load execution logs.');
this.logsLoading.set(false);
}
});
}
private isContainerExecutionNode(stepNode: FlowNode): boolean {
if (stepNode.nodeFamily === 'container') return true;
const subFlow = (stepNode.specificConfiguration as Record<string, unknown> | null | undefined)?.['subFlow'];
if (subFlow && typeof subFlow === 'object' && !Array.isArray(subFlow)) return true;
return !!this.containersService.peekContainerType(stepNode.typeName);
}
private scrollLogsToBottom() {
const element = this.logsScrollViewport()?.nativeElement;
if (!element || this.activeAsideTab() !== 'logs') return;
element.scrollTop = element.scrollHeight;
}
private async loadSimulationOptions(
key: string,
context: Record<string, string>,
retrieverUrl: string
): Promise<Array<{ label: string; value: string }>> {
const values = await firstValueFrom(
this.fieldRetriever.retrieveValues('LLM', key, context, retrieverUrl)
);
return values.map((value) => ({
label: value,
value
}));
}
logLevelClass(level: string | null | undefined): string {
return _logLevelClass(level);
}
logTypeIcon(type: string | null | undefined): string {
return _logTypeIcon(type);
}
private inferConnections(steps: TaskExecutionStep[]) {
const connections: FlowData['connections'] = [];
const indexedSteps = steps.map((step, index) => ({ step, index }));
for (const targetEntry of indexedSteps) {
const targetStep = targetEntry.step;
for (const input of targetStep.inputs ?? []) {
if (!input.registered) continue;
const candidates = indexedSteps
.filter(({ step }) => step.id !== targetStep.id)
.flatMap((sourceEntry) =>
(sourceEntry.step.outputs ?? [])
.filter((output) => output.connected)
.filter((output) =>
areFlowValueKindsCompatible(
normalizeFlowPortValueKinds(output.descriptor),
normalizeFlowPortValueKinds(input.descriptor)
)
)
.map((output) => ({
sourceStep: sourceEntry.step,
sourceIndex: sourceEntry.index,
sourceOutputName: output.descriptor.name
}))
);
if (!candidates.length) continue;
const source = this.pickBestConnectionCandidate(candidates, targetEntry.index);
const id = `${source.sourceStep.id}_${source.sourceOutputName}_${targetStep.id}_${input.descriptor.name}`;
if (connections.some((connection) => connection.id === id)) continue;
connections.push({
id,
sourceId: stepNodeId(source.sourceStep),
sourceName: source.sourceOutputName,
targetId: stepNodeId(targetStep),
targetName: input.descriptor.name
});
}
}
return connections;
}
private getExecutionConnections(
steps: TaskExecutionStep[],
sourceFlow: FlowData | null
): FlowBlockConnection[] {
return resolveExecutionConnections(
this.execution(),
steps,
sourceFlow,
this.inferConnections(steps)
);
}
private getExecutionDependencies(
steps = this.stepsArray(),
sourceFlow = this.execution()?.flowSnapshot ?? this.sourceFlowData()
): FlowNodeDependency[] {
return resolveExecutionDependencies(this.execution(), steps, sourceFlow);
}
private hasIncomingDependency(stepId: string): boolean {
return this.getExecutionDependencies().some((dependency) => String(dependency.targetId) === stepId);
}
private async biasRerunCandidates(): Promise<BiasRerunCandidate[]> {
const candidates = this.stepsArray().flatMap((step): Array<{ nodeId: string; nodeName: string; node: FlowNode }> => {
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 block = node as FlowBlock;
const annotations = (block.biasAnnotations ?? []).filter((annotation) => isProbeExecutable(annotation.biasProbe) || isProbeExecutable(annotation.mitigationProbe));
return annotations.length ? [{ nodeId: step.id, nodeName: node.name || step.id, node: { ...block, biasAnnotations: annotations } }] : [];
});
const resolved = await Promise.all(candidates.map(async (candidate) => {
const capabilities = await firstValueFrom(
candidate.node.nodeFamily === 'container'
? this.containersService.retrieveBiasCapabilities(candidate.node.typeName)
: this.blocksService.retrieveBiasCapabilities(candidate.node.typeName)
);
if (!capabilities.fullFlowExperimentSupported) return null;
return {
nodeId: candidate.nodeId,
nodeName: candidate.nodeName,
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);
}
private hasOutgoingDependency(stepId: string): boolean {
return this.getExecutionDependencies().some((dependency) => String(dependency.sourceId) === stepId);
}
private pickBestConnectionCandidate(
candidates: Array<{ sourceStep: TaskExecutionStep; sourceIndex: number; sourceOutputName: string }>,
targetIndex: number
) {
const previousCandidates = candidates
.filter((candidate) => candidate.sourceIndex < targetIndex)
.sort((left, right) => right.sourceIndex - left.sourceIndex);
if (previousCandidates.length) {
return previousCandidates[0];
}
return [...candidates].sort((left, right) => left.sourceIndex - right.sourceIndex)[0];
}
private toDialogHistory(rawHistory: unknown): HumanInteractionChatMessage[] {
if (!Array.isArray(rawHistory)) return [];
return rawHistory
.map((entry) => {
if (typeof entry === 'string') {
return this.parseChatHistoryLine(entry);
}
if (!entry || typeof entry !== 'object') return null;
const record = entry as Record<string, unknown>;
const role = record['role'];
const content = record['content'] ?? record['message'] ?? record['text'];
if ((role !== 'user' && role !== 'assistant' && role !== 'system') || typeof content !== 'string') {
return null;
}
return { role, content };
})
.filter((entry): entry is HumanInteractionChatMessage => entry != null);
}
private parseChatHistoryLine(rawLine: string): HumanInteractionChatMessage | null {
const line = rawLine.trim();
if (!line) return null;
const prefixed = line.match(/^\[(USER|ASSISTANT|SYSTEM)\]\s*([\s\S]*)$/i);
if (prefixed) {
const role = prefixed[1].toLowerCase() as 'user' | 'assistant' | 'system';
return {
role,
content: prefixed[2] ?? ''
};
}
return {
role: 'assistant',
content: line
};
}
}