538 lines
19 KiB
TypeScript
538 lines
19 KiB
TypeScript
import {
|
|
areFlowValueKindsCompatible,
|
|
FlowBlock,
|
|
FlowBlockConnection,
|
|
FlowContainer,
|
|
FlowData,
|
|
FlowNode,
|
|
FlowNodeDependency,
|
|
normalizeFlowPortValueKinds,
|
|
} from '@models/flow';
|
|
import {
|
|
ExecutionEventLogEntry,
|
|
getExecutionStatusGroup,
|
|
getTaskExecutionStepNode,
|
|
TaskExecution,
|
|
TaskExecutionAuthorizationRequirement,
|
|
TaskExecutionStep,
|
|
} from '@models/task-execution';
|
|
import { LlmProviderCapability } from '@models/llm-provider';
|
|
|
|
export type ExecutionOutputEntry = {
|
|
key: string;
|
|
nodeTitle: string;
|
|
outputName: string;
|
|
value: string;
|
|
preview: string;
|
|
isLong: boolean;
|
|
itemLabel: string | null;
|
|
};
|
|
|
|
export type ExecutionIntermediateInputEntry = {
|
|
key: string;
|
|
nodeTitle: string;
|
|
inputName: string;
|
|
inputType: string;
|
|
sourceLabel: string;
|
|
value: string;
|
|
preview: string;
|
|
isLong: boolean;
|
|
itemLabel: string | null;
|
|
};
|
|
|
|
export type ExecutionOutputGroup = {
|
|
nodeTitle: string;
|
|
outputs: ExecutionOutputEntry[];
|
|
};
|
|
|
|
export type ExecutionIntermediateInputGroup = {
|
|
nodeTitle: string;
|
|
inputs: ExecutionIntermediateInputEntry[];
|
|
};
|
|
|
|
export type ExecutionLogEntryView = ExecutionEventLogEntry & {
|
|
messageText: string;
|
|
levelText: string;
|
|
innerExecutionId: string | null;
|
|
innerNodeId: string | null;
|
|
innerStepId: string | null;
|
|
iterationIndex: string | null;
|
|
containerType: string | null;
|
|
};
|
|
|
|
const OUTPUT_PREVIEW_LIMIT = 80;
|
|
const INTERMEDIATE_INPUT_PREVIEW_LIMIT = 120;
|
|
|
|
export function stepTitle(step: TaskExecutionStep | null | undefined): string {
|
|
return getTaskExecutionStepNode(step)?.name?.trim() || step?.id || 'Step';
|
|
}
|
|
|
|
export function stepNodeId(step: TaskExecutionStep | null | undefined): string {
|
|
return getTaskExecutionStepNode(step)?.id || step?.id || '';
|
|
}
|
|
|
|
export function stringifyOutputValue(value: unknown): string {
|
|
if (value == null) return '';
|
|
if (typeof value === 'string') return value;
|
|
try {
|
|
return JSON.stringify(value, null, 2);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
export function formatDuration(startTime: number, endTime: number): string {
|
|
const diffMs = Math.max(0, endTime - startTime);
|
|
const totalSeconds = Math.floor(diffMs / 1000);
|
|
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
const totalHours = Math.floor(totalMinutes / 60);
|
|
const totalDays = Math.floor(totalHours / 24);
|
|
|
|
if (totalSeconds < 60) return `${totalSeconds} sec`;
|
|
if (totalMinutes < 60) {
|
|
const seconds = totalSeconds % 60;
|
|
return seconds > 0 ? `${totalMinutes} min ${seconds} sec` : `${totalMinutes} min`;
|
|
}
|
|
if (totalHours < 24) {
|
|
const minutes = totalMinutes % 60;
|
|
return minutes > 0 ? `${totalHours} h ${minutes} min` : `${totalHours} h`;
|
|
}
|
|
const hours = totalHours % 24;
|
|
return hours > 0 ? `${totalDays} gg ${hours} h` : `${totalDays} gg`;
|
|
}
|
|
|
|
export function fallbackExecutionLogMessage(entry: ExecutionEventLogEntry): string {
|
|
const type = String(entry.type ?? '').trim();
|
|
if (type) {
|
|
return type.replaceAll('_', ' ').toLowerCase().replace(/^\w/, (letter) => letter.toUpperCase());
|
|
}
|
|
return 'Execution event';
|
|
}
|
|
|
|
export function logLevelClass(level: string | null | undefined): string {
|
|
const normalized = String(level ?? '').toUpperCase();
|
|
if (normalized === 'ERROR') return 'execution-log-level-error';
|
|
if (normalized === 'WARN' || normalized === 'WARNING') return 'execution-log-level-warn';
|
|
return 'execution-log-level-info';
|
|
}
|
|
|
|
export function logTypeIcon(type: string | null | undefined): string {
|
|
const normalized = String(type ?? '').toUpperCase();
|
|
if (normalized.includes('FAILED') || normalized.includes('ERROR')) return 'error';
|
|
if (normalized.includes('WAITING') || normalized.includes('PAUSED')) return 'pause_circle';
|
|
if (normalized.includes('COMPLETED') || normalized.includes('SUCCESS')) return 'check_circle';
|
|
if (normalized.includes('HTTP')) return 'language';
|
|
if (normalized.includes('LLM')) return 'smart_toy';
|
|
if (normalized.includes('MCP_SESSION')) return 'hub';
|
|
return 'schedule';
|
|
}
|
|
|
|
export function formatExecutionOutputLabel(
|
|
key: string,
|
|
steps: Record<string, TaskExecutionStep>
|
|
): { nodeTitle: string; outputName: string } {
|
|
const separatorIndex = key.indexOf(':');
|
|
if (separatorIndex < 0) {
|
|
return { nodeTitle: 'Execution output', outputName: key };
|
|
}
|
|
const nodeId = key.slice(0, separatorIndex);
|
|
const outputName = key.slice(separatorIndex + 1);
|
|
const step = steps[nodeId];
|
|
const nodeName = stepTitle(step).trim();
|
|
return {
|
|
nodeTitle: nodeName || key,
|
|
outputName: outputName || 'Execution output',
|
|
};
|
|
}
|
|
|
|
export function buildExecutionOutputs(execution: TaskExecution | null): ExecutionOutputEntry[] {
|
|
if (!execution) return [];
|
|
const steps = execution.context.steps ?? {};
|
|
const resultMap = execution.context.result ?? {};
|
|
|
|
return Object.entries(resultMap)
|
|
.flatMap(([key, rawValue]) => {
|
|
const label = formatExecutionOutputLabel(key, steps);
|
|
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
|
|
return values.map((item, index) => {
|
|
const value = stringifyOutputValue(item);
|
|
const isLong = value.length > OUTPUT_PREVIEW_LIMIT;
|
|
const isArrayItem = Array.isArray(rawValue);
|
|
return {
|
|
key: isArrayItem ? `${key}:${index}` : key,
|
|
nodeTitle: label.nodeTitle,
|
|
outputName: label.outputName,
|
|
value,
|
|
preview: isLong ? `${value.slice(0, OUTPUT_PREVIEW_LIMIT)}...` : value,
|
|
isLong,
|
|
itemLabel: isArrayItem ? `Item ${index + 1}` : null,
|
|
};
|
|
});
|
|
})
|
|
.sort((a, b) => a.nodeTitle.localeCompare(b.nodeTitle) || a.outputName.localeCompare(b.outputName));
|
|
}
|
|
|
|
export function buildExecutionOutputGroups(outputs: ExecutionOutputEntry[]): ExecutionOutputGroup[] {
|
|
const groups = new Map<string, ExecutionOutputEntry[]>();
|
|
for (const output of outputs) {
|
|
if (!groups.has(output.nodeTitle)) groups.set(output.nodeTitle, []);
|
|
groups.get(output.nodeTitle)!.push(output);
|
|
}
|
|
return Array.from(groups.entries())
|
|
.map(([nodeTitle, outs]) => ({ nodeTitle, outputs: outs }))
|
|
.sort((a, b) => a.nodeTitle.localeCompare(b.nodeTitle));
|
|
}
|
|
|
|
export function buildExecutionIntermediateInputs(execution: TaskExecution | null): ExecutionIntermediateInputEntry[] {
|
|
if (!execution) return [];
|
|
const contextInputs = execution.context.inputs ?? {};
|
|
|
|
return Object.values(execution.context.steps ?? {})
|
|
.flatMap((step) => {
|
|
const inputValues = getExecutionInputValues(step, contextInputs);
|
|
return Object.entries(inputValues).flatMap(([inputName, rawValue]) => {
|
|
const input = (step.inputs ?? []).find((candidate) => candidate.descriptor?.name === inputName);
|
|
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
|
|
const isArrayValue = Array.isArray(rawValue);
|
|
|
|
return values.map((item, index) => {
|
|
const value = stringifyOutputValue(item);
|
|
const isLong = value.length > INTERMEDIATE_INPUT_PREVIEW_LIMIT;
|
|
return {
|
|
key: isArrayValue ? `${step.id}:${inputName}:${index}` : `${step.id}:${inputName}`,
|
|
nodeTitle: stepTitle(step),
|
|
inputName,
|
|
inputType: String(input?.descriptor?.type ?? 'TEXT').toUpperCase(),
|
|
sourceLabel: input?.registered ? 'Connected input' : 'Prepared input',
|
|
value,
|
|
preview: isLong ? `${value.slice(0, INTERMEDIATE_INPUT_PREVIEW_LIMIT)}...` : value,
|
|
isLong,
|
|
itemLabel: isArrayValue ? `Item ${index + 1}` : null,
|
|
};
|
|
});
|
|
});
|
|
})
|
|
.filter((entry) => entry.value.trim().length > 0)
|
|
.sort((a, b) => a.nodeTitle.localeCompare(b.nodeTitle) || a.inputName.localeCompare(b.inputName));
|
|
}
|
|
|
|
export function buildExecutionIntermediateInputGroups(
|
|
inputs: ExecutionIntermediateInputEntry[]
|
|
): ExecutionIntermediateInputGroup[] {
|
|
const groups = new Map<string, ExecutionIntermediateInputEntry[]>();
|
|
for (const input of inputs) {
|
|
if (!groups.has(input.nodeTitle)) groups.set(input.nodeTitle, []);
|
|
groups.get(input.nodeTitle)!.push(input);
|
|
}
|
|
return Array.from(groups.entries())
|
|
.map(([nodeTitle, items]) => ({ nodeTitle, inputs: items }))
|
|
.sort((a, b) => a.nodeTitle.localeCompare(b.nodeTitle));
|
|
}
|
|
|
|
export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): ExecutionLogEntryView[] {
|
|
return [...logs]
|
|
.sort((a, b) => a.timestamp - b.timestamp)
|
|
.map((entry) => {
|
|
const details = entry.details && typeof entry.details === 'object' && !Array.isArray(entry.details)
|
|
? entry.details as Record<string, unknown>
|
|
: {};
|
|
return {
|
|
...entry,
|
|
messageText: String(entry.message ?? '').trim() || fallbackExecutionLogMessage(entry),
|
|
levelText: String(entry.level ?? 'INFO').toUpperCase(),
|
|
innerExecutionId: nonEmptyLogDetail(details['innerExecutionId']),
|
|
innerNodeId: nonEmptyLogDetail(details['innerNodeId']),
|
|
innerStepId: nonEmptyLogDetail(details['innerStepId']),
|
|
iterationIndex: nonEmptyLogDetail(details['iterationIndex']),
|
|
containerType: nonEmptyLogDetail(details['containerType'])
|
|
};
|
|
});
|
|
}
|
|
|
|
function nonEmptyLogDetail(value: unknown): string | null {
|
|
const normalized = value == null ? '' : String(value).trim();
|
|
return normalized || null;
|
|
}
|
|
|
|
export function isInputSet(value: unknown, multiple = false): boolean {
|
|
if (multiple) {
|
|
if (!Array.isArray(value)) return false;
|
|
return value.some((item) => (typeof item === 'string' ? item.trim().length > 0 : item != null));
|
|
}
|
|
if (value == null) return false;
|
|
if (typeof value === 'string') return value.trim().length > 0;
|
|
return true;
|
|
}
|
|
|
|
export function normalizeEditableInputValue(value: unknown, multiple: boolean): string | string[] {
|
|
if (multiple) {
|
|
if (Array.isArray(value)) return value.map((item) => stringifyEditableInputItem(item));
|
|
if (value == null) return [''];
|
|
return [stringifyEditableInputItem(value)];
|
|
}
|
|
if (value == null) return '';
|
|
return stringifyEditableInputItem(value);
|
|
}
|
|
|
|
export function stringifyEditableInputItem(value: unknown): string {
|
|
if (value == null) return '';
|
|
if (typeof value === 'string') return value;
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
export function getExecutionInputValues(
|
|
step: TaskExecutionStep,
|
|
contextInputs: Record<string, unknown>
|
|
): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
const runtimeInputs = step.inputs ?? [];
|
|
const inputDescriptors = runtimeInputs.length
|
|
? runtimeInputs.map((input) => input.descriptor)
|
|
: getTaskExecutionStepNode(step)?.inputs ?? [];
|
|
for (const descriptor of inputDescriptors) {
|
|
const inputName = descriptor?.name;
|
|
if (!inputName) continue;
|
|
const key = `${step.id}:${inputName}`;
|
|
if (Object.prototype.hasOwnProperty.call(contextInputs, key)) {
|
|
result[inputName] = contextInputs[key];
|
|
continue;
|
|
}
|
|
const input = runtimeInputs.find((candidate) => candidate.descriptor?.name === inputName);
|
|
if (!input) continue;
|
|
if (input.set || input.registered || input.value != null) {
|
|
result[inputName] = input.value;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function getExecutionOutputValues(
|
|
step: TaskExecutionStep,
|
|
contextResults: Record<string, unknown>
|
|
): Record<string, unknown> {
|
|
const result: Record<string, unknown> = {};
|
|
const runtimeOutputs = step.outputs ?? [];
|
|
const outputDescriptors = runtimeOutputs.length
|
|
? runtimeOutputs.map((output) => output.descriptor)
|
|
: getTaskExecutionStepNode(step)?.outputs ?? [];
|
|
for (const descriptor of outputDescriptors) {
|
|
const outputName = descriptor?.name;
|
|
if (!outputName) continue;
|
|
const key = `${step.id}:${outputName}`;
|
|
if (Object.prototype.hasOwnProperty.call(contextResults, key)) {
|
|
result[outputName] = contextResults[key];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function getConnectedInputs(step: TaskExecutionStep): string[] {
|
|
return (step.inputs ?? [])
|
|
.filter((input) => input.registered)
|
|
.map((input) => input.descriptor?.name)
|
|
.filter((name): name is string => typeof name === 'string' && name.length > 0);
|
|
}
|
|
|
|
export function getConnectedOutputs(step: TaskExecutionStep): string[] {
|
|
return (step.outputs ?? [])
|
|
.filter((output) => output.connected)
|
|
.map((output) => output.descriptor?.name)
|
|
.filter((name): name is string => typeof name === 'string' && name.length > 0);
|
|
}
|
|
|
|
export function getExecutionErrors(stepId: string, contextErrors: Record<string, unknown>): string[] {
|
|
const raw = contextErrors[stepId];
|
|
if (typeof raw === 'string' && raw.trim().length > 0) return [raw];
|
|
if (Array.isArray(raw)) {
|
|
return raw.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export function getExecutionWarnings(stepId: string, contextWarnings: unknown): string[] {
|
|
if (!contextWarnings || typeof contextWarnings !== 'object') return [];
|
|
if (Array.isArray(contextWarnings)) {
|
|
return contextWarnings.flatMap((warning) => {
|
|
if (typeof warning === 'string') return [warning];
|
|
if (!warning || typeof warning !== 'object') return [];
|
|
const value = warning as Record<string, unknown>;
|
|
const warningStepId = value['stepId'] ?? value['nodeId'];
|
|
const message = value['message'] ?? value['warning'];
|
|
return String(warningStepId ?? '') === stepId && typeof message === 'string' && message.trim()
|
|
? [message]
|
|
: [];
|
|
});
|
|
}
|
|
const raw = (contextWarnings as Record<string, unknown>)[stepId];
|
|
if (typeof raw === 'string' && raw.trim().length > 0) return [raw];
|
|
if (Array.isArray(raw)) {
|
|
return raw.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Authorization gate
|
|
*
|
|
* An execution cannot start until every entry of `requiredAuthorizations`
|
|
* has been satisfied. `missingAuthorizationKeys` is the backend's answer on
|
|
* what is still outstanding and is the only thing the gate trusts.
|
|
* ------------------------------------------------------------------ */
|
|
|
|
/** Keys of the form `LLMProvider::<provider>::<field>`, always paid with a vault secret id. */
|
|
export const LLM_AUTHORIZATION_KEY_PREFIX = 'llmprovider::';
|
|
|
|
export type AuthorizationCapabilityState = {
|
|
capabilities: LlmProviderCapability[];
|
|
loading: boolean;
|
|
failed: boolean;
|
|
};
|
|
|
|
export type VaultAuthorizationEntry = {
|
|
requirement: TaskExecutionAuthorizationRequirement;
|
|
provider: string;
|
|
/** `null` while the provider catalog is unavailable, so the UI cannot claim either way. */
|
|
requiresCredential: boolean | null;
|
|
};
|
|
|
|
export type AuthorizationGate = {
|
|
/** Vault credentials the execution is still waiting for. */
|
|
vault: VaultAuthorizationEntry[];
|
|
/** Vault credentials already provided, kept so the user can review or change them. */
|
|
satisfiedVault: VaultAuthorizationEntry[];
|
|
/** Outstanding authorizations that are not provider credentials and carry a literal value. */
|
|
runtime: TaskExecutionAuthorizationRequirement[];
|
|
missingProviders: string[];
|
|
satisfied: boolean;
|
|
};
|
|
|
|
export function authorizationProvider(requirement: TaskExecutionAuthorizationRequirement): string {
|
|
const provider = String(requirement.provider ?? '').trim();
|
|
if (provider && !provider.includes('::')) return provider;
|
|
const parts = String(requirement.key ?? '').split('::');
|
|
return parts.length > 1 ? parts[1].trim() : provider;
|
|
}
|
|
|
|
export function isLlmAuthorizationRequirement(requirement: TaskExecutionAuthorizationRequirement): boolean {
|
|
return String(requirement.key ?? '').trim().toLowerCase().startsWith(LLM_AUTHORIZATION_KEY_PREFIX);
|
|
}
|
|
|
|
export function listAuthorizationRequirements(
|
|
execution: TaskExecution | null | undefined
|
|
): TaskExecutionAuthorizationRequirement[] {
|
|
const required = execution?.requiredAuthorizations;
|
|
if (!required) return [];
|
|
const entries = Array.isArray(required) ? required : Object.values(required);
|
|
return entries
|
|
.filter((entry): entry is TaskExecutionAuthorizationRequirement =>
|
|
!!entry && typeof entry.key === 'string' && entry.key.trim().length > 0
|
|
)
|
|
.sort((left, right) =>
|
|
authorizationProvider(left).localeCompare(authorizationProvider(right))
|
|
|| left.key.localeCompare(right.key)
|
|
);
|
|
}
|
|
|
|
export function buildAuthorizationGate(
|
|
execution: TaskExecution | null | undefined,
|
|
capabilityState: AuthorizationCapabilityState
|
|
): AuthorizationGate {
|
|
const missingKeys = new Set(execution?.missingAuthorizationKeys ?? []);
|
|
const vault: VaultAuthorizationEntry[] = [];
|
|
const satisfiedVault: VaultAuthorizationEntry[] = [];
|
|
const runtime: TaskExecutionAuthorizationRequirement[] = [];
|
|
|
|
for (const requirement of listAuthorizationRequirements(execution)) {
|
|
const missing = missingKeys.has(requirement.key);
|
|
|
|
// A provider key is never payable with a literal value, whatever the catalog says
|
|
// or fails to say, so it can only ever go down the vault path.
|
|
if (isLlmAuthorizationRequirement(requirement)) {
|
|
const provider = authorizationProvider(requirement);
|
|
const entry: VaultAuthorizationEntry = {
|
|
requirement,
|
|
provider,
|
|
requiresCredential: resolveRequiresCredential(provider, capabilityState)
|
|
};
|
|
(missing ? vault : satisfiedVault).push(entry);
|
|
continue;
|
|
}
|
|
|
|
if (missing) runtime.push(requirement);
|
|
}
|
|
|
|
const missingProviders = Array.from(new Set(vault.map((entry) => entry.provider))).filter(Boolean);
|
|
|
|
return {
|
|
vault,
|
|
satisfiedVault,
|
|
runtime,
|
|
missingProviders,
|
|
satisfied: vault.length === 0 && runtime.length === 0
|
|
};
|
|
}
|
|
|
|
function resolveRequiresCredential(
|
|
provider: string,
|
|
capabilityState: AuthorizationCapabilityState
|
|
): boolean | null {
|
|
if (capabilityState.loading || capabilityState.failed) return null;
|
|
const wanted = provider.trim().toLowerCase();
|
|
const match = capabilityState.capabilities.find((capability) =>
|
|
capability.name.trim().toLowerCase() === wanted
|
|
);
|
|
return match ? match.requiresCredential : null;
|
|
}
|
|
|
|
/**
|
|
* Everything that has to hold before an execution can be started, except the
|
|
* conditions only the component knows about (a subflow view, a request already
|
|
* in flight).
|
|
*/
|
|
export function isExecutionStartable(
|
|
execution: TaskExecution | null | undefined,
|
|
gate: AuthorizationGate
|
|
): boolean {
|
|
if (!execution) return false;
|
|
if (!gate.satisfied) return false;
|
|
|
|
if (getExecutionStatusGroup(execution.context.status) !== 'INIT') return false;
|
|
const status = String(execution.context.status ?? '').toUpperCase();
|
|
if (status !== 'CREATED' && status !== 'READY') return false;
|
|
|
|
const globalInputs = 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) return false;
|
|
|
|
const value = Object.prototype.hasOwnProperty.call(globalInputs, inputName)
|
|
? globalInputs[inputName]
|
|
: descriptor?.value;
|
|
if (!isInputSet(value, Boolean(descriptor?.multiple))) return false;
|
|
}
|
|
|
|
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 value = Object.prototype.hasOwnProperty.call(execution.context.inputs ?? {}, key)
|
|
? execution.context.inputs[key]
|
|
: input.value;
|
|
|
|
if (!isInputSet(value, Boolean(input.descriptor?.multiple))) return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|