import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, HostListener, model, inject, input, OnDestroy, signal, viewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { NotificationService } from '@services/notifications/notification'; 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, InputCopySource, TaskExecutionInputsPanelComponent } from '@shared/task-execution-inputs-panel/task-execution-inputs-panel'; import { describeInputCopy, planInputCopy } from '@shared/task-execution-inputs-panel/input-copy'; 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, readSimulatorParameters, 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 notifications = inject(NotificationService); private router = inject(Router); private route = inject(ActivatedRoute); private lastExecutionId: string | null = null; private lastExecutionStatus: string | null = null; private static readonly SIMULATOR_PARAMETER_GROUP = 'Model parameters'; /** * The optional sampling knobs, behind a section that starts closed. Provider and model are what * anyone opening this dialog came for; these are for the runs where you already know you want * them, and shown flat they made the common case look like a five-field form. */ private static readonly SIMULATOR_PARAMETER_FIELDS: NodeSettingField[] = [ { key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 2, group: TaskExecutionViewerComponent.SIMULATOR_PARAMETER_GROUP, placeholder: 'Leave empty for the default', tip: '0 makes the run as repeatable as the model allows' }, { key: 'topP', label: 'Top P', type: 'number', min: 0, max: 1, group: TaskExecutionViewerComponent.SIMULATOR_PARAMETER_GROUP, placeholder: 'Leave empty for the default' }, { key: 'topK', label: 'Top K', type: 'number', min: 1, group: TaskExecutionViewerComponent.SIMULATOR_PARAMETER_GROUP, placeholder: 'Leave empty for the default' }, { key: 'maxTokens', label: 'Max tokens', type: 'number', min: 1, group: TaskExecutionViewerComponent.SIMULATOR_PARAMETER_GROUP, placeholder: 'Leave empty for the default' }, { key: 'seed', label: 'Seed', type: 'number', group: TaskExecutionViewerComponent.SIMULATOR_PARAMETER_GROUP, placeholder: 'Leave empty for the default', tip: 'Fixes the randomness, so two runs can be compared' } ]; private static readonly SIMULATOR_PROVIDER_RETRIEVER_URL = '/retriever/LLM/providers'; private static readonly SIMULATOR_MODEL_RETRIEVER_URL = '/retriever/LLM/models'; readonly execution = input(null); readonly parentExecution = input(null); readonly parentContainerStep = input(null); readonly contextAsideOpen = signal(true); /** * Exposed as a model so the page can follow it: fullscreen covers the rail, and the execution * tree lives there. Without the tree there is no way back from a container's child run to the * parent, which turns fullscreen into a one-way door on exactly the runs that need navigating. */ readonly isFullscreen = model(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>({}); readonly savingErrors = signal>({}); readonly pendingTextInputs = signal>({}); /** Edited but not yet sent, so the panel can offer one Save for the lot. */ readonly pendingInputKeys = computed(() => Object.keys(this.pendingTextInputs())); /** What the last copy from another run did, cleared as soon as the user edits anything. */ readonly inputCopySummary = signal(null); /** * Other runs of the same flow whose inputs could be reused, newest first. * * Confined to the execution's own group, which is keyed by source flow: a run of a different * flow has different inputs, and matching them by name would be a coincidence, not a copy. */ readonly inputCopySources = computed(() => { const execution = this.execution(); if (!execution || this.inputsReadOnly()) return []; const group = this.taskExecutionsService.taskExecutionGroups() .find((candidate) => candidate.executions.some((one) => one.id === execution.id)); if (!group) return []; return group.executions .filter((candidate) => candidate.id !== execution.id) .sort((a, b) => (b.runNumber ?? 0) - (a.runNumber ?? 0) || b.creationTime - a.creationTime) .map((candidate) => ({ id: candidate.id, label: candidate.runNumber ? `Run #${candidate.runNumber}` : candidate.name || candidate.id, detail: [ candidate.context?.status, isBiasVariantContext(candidate.biasExecutionContext) ? 'bias variant' : null ].filter(Boolean).join(' \u00b7 ') })); }); /** Fills the pending edits from another run, so the panel's single Save still governs the write. */ copyInputsFrom(executionId: string) { if (this.inputsReadOnly()) return; const source = this.taskExecutionsService.taskExecutionGroups() .flatMap((group) => group.executions) .find((candidate) => candidate.id === executionId); if (!source) return; const plan = planInputCopy(this.editableInputs(), source); for (const { input, value } of plan.copied) { this.pendingTextInputs.update((current) => ({ ...current, [input.key]: value })); } this.globalSaveError.set(null); this.inputCopySummary.set(describeInputCopy(plan)); } /** Set when the single request carrying every edited global fails; cleared on the next edit. */ readonly globalSaveError = signal(null); readonly pendingAuthorizationValues = signal>({}); readonly savingAuthorizations = signal>({}); readonly authorizationErrors = signal>({}); readonly llmProviderCapabilities = signal([]); readonly llmProviderCapabilitiesLoading = signal(false); readonly llmProviderCapabilitiesError = signal(null); readonly llmCredentialOptions = signal>({}); readonly llmCredentialLoading = signal>({}); readonly llmCredentialErrors = signal>({}); /** Satisfied requirements the user reopened to pick a different credential. */ readonly editingAuthorizationKeys = signal>({}); readonly credentialFormSaving = signal(false); readonly credentialFormError = signal(null); readonly outputPreviewModal = signal(null); readonly intermediateInputPreviewModal = signal(null); readonly executionLogs = signal([]); readonly sourceFlowData = signal(null); readonly sourceFlowLoading = signal(false); readonly logsLoading = signal(false); readonly logsError = signal(null); private readonly logsScrollViewport = viewChild>('logsScrollViewport'); private sourceFlowRequestVersion = 0; private readonly sourceFlowCache = new Map(); private readonly requestedCredentialProviders = new Set(); 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.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 : {}; 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(() => 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(() => { 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(() => { const editing = this.editingAuthorizationKeys(); return this.authorizationGate().satisfiedVault.filter((entry) => !editing[entry.requirement.key]); }); readonly runtimeAuthorizationRequirements = computed(() => this.authorizationGate().runtime); readonly executionFlowData = computed(() => { 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(); 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(() => buildExecutionOutputs(this.execution()) ); readonly executionOutputGroups = computed(() => buildExecutionOutputGroups(this.executionOutputs()) ); readonly executionIntermediateInputs = computed(() => buildExecutionIntermediateInputs(this.execution()) ); readonly executionIntermediateInputGroups = computed(() => buildExecutionIntermediateInputGroups(this.executionIntermediateInputs()) ); readonly inputsReadOnly = computed(() => { const status = this.execution()?.context.status; return getExecutionStatusGroup(status) !== 'INIT'; }); readonly visibleExecutionLogs = computed(() => 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'; // Names who answers the flow's human steps, which is the only thing that separates this from // the simulated run beside it - and the part an icon can only gesture at. return 'Run - you answer the human steps'; }); 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() ); /** How many nodes an experiment could act on. Sync, so the empty state can say it for free. */ readonly biasAnnotatedNodeCount = computed(() => this.biasAnnotatedNodes().length); /** * Why a bias experiment cannot be started here, or null when it can. * * The preconditions were only ever expressed as a disabled control or, worse, a silent return. * Stated as a sentence they are also what the empty Bias impact tab needs to explain itself. */ readonly biasExperimentBlockedReason = computed(() => { if (this.isSubflowExecution()) { return 'A subflow run cannot be a baseline. Open its parent run to start an experiment.'; } if (getExecutionStatusGroup(this.execution()?.context.status) !== 'FINAL') { return 'This run has not finished yet. An experiment compares it against a rerun, so it needs a completed baseline.'; } if (this.biasAnnotatedNodeCount() === 0) { return 'No node in this flow carries a bias probe that can be activated. Add an instruction to a bias or mitigation annotation in the editor first.'; } return null; }); readonly canCompareBiasExecution = computed(() => !this.isSubflowExecution() && this.isBiasVariant() && !!this.execution()?.rerunOfExecutionId && getExecutionStatusGroup(this.execution()?.context.status) === 'FINAL' ); readonly subflowIterationIndex = computed(() => { const executionIndex = this.execution()?.parentIterationIndex; if (typeof executionIndex === 'number') return executionIndex; const stepIndex = this.parentContainerStep()?.containerIterationIndex; return typeof stepIndex === 'number' ? stepIndex : null; }); readonly simulationDescriptorLabel = computed(() => { const descriptor = this.execution()?.interactionSimulationDescriptor; if (!descriptor) return null; 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(() => { 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); } /** * A modal, like the copilot's: the form used to render further down the aside, so on a long run * pressing Add credential scrolled nothing into view and looked like it did nothing. */ async openVaultCredentialForm(provider: string) { this.credentialFormError.set(null); // The provider is fixed by the requirement being answered, so it is shown rather than chosen. const fields: NodeSettingField[] = [ { key: 'provider', label: 'Provider', type: 'display' }, { key: 'label', label: 'Label', type: 'text', required: true, autofocus: true }, { key: 'description', label: 'Description (optional)', type: 'text' }, { key: 'value', label: 'API key', type: 'password', required: true, tip: 'The value will not be shown after saving.' } ]; const result = await this.settingsDialog.open({ title: `Add ${provider} credential`, fields, initial: { provider, label: '', description: '', value: '' } }); if (!result) return; this.saveExecutionCredential( provider, String(result['label'] ?? '').trim(), String(result['description'] ?? '').trim(), String(result['value'] ?? '') ); } private saveExecutionCredential(provider: string, label: string, description: string, value: string) { if (!provider || !label || !value.trim() || this.credentialFormSaving()) return; this.credentialFormSaving.set(true); this.vaultService.createSecret({ provider, label, description: description || undefined, value }).pipe(take(1)).subscribe({ next: (credential) => { this.credentialFormSaving.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.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 { 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) { // Returning silently made the button indistinguishable from a broken one. The reason is // knowable: either nothing carries an activatable probe, or the nodes that do are of a // type the backend will not run a full-flow experiment on. this.notifications.show( this.biasExperimentBlockedReason() ?? 'None of the annotated nodes in this flow support a full-flow bias experiment.', 'info', 6000 ); 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.inputCopySummary.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. * *

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[] = []; 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, executionId: string ): Observable { 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 { 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 { 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 }, // Optional, and empty means "leave it to the provider" - the same contract the block editor // gives them. A simulated run is a model call like any other, and a seed here is what makes // a simulated run repeatable enough to compare against another. ...TaskExecutionViewerComponent.SIMULATOR_PARAMETER_FIELDS ]; 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; } const parameters = readSimulatorParameters(result); return parameters ? { provider, model, parameters } : { 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 | 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, retrieverUrl: string ): Promise> { 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); } /** * The nodes carrying something an experiment could activate. Split out of biasRerunCandidates * because it needs no network: the capability check does, and the empty state wants a count * without firing a request per node type just to render a sentence. */ private biasAnnotatedNodes(): Array<{ nodeId: string; nodeName: string; node: FlowNode }> { return 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 } }] : []; }); } private async biasRerunCandidates(): Promise { const candidates = this.biasAnnotatedNodes(); 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; 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 }; } }