diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index 1a3b2b0..b9d1f7d 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -118,6 +118,8 @@ export type FlowNodeBase = { typeName: BlockTypeName; nodeFamily?: NodeFamily; laneId?: string | null; + capabilities?: NodeTypeCapabilities; + userInteractive?: boolean; }; export type BiasActivationMode = diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts index cdeb286..ec93453 100644 --- a/src/app/models/task-execution.ts +++ b/src/app/models/task-execution.ts @@ -1,4 +1,4 @@ -import { FlowBlockConnection, FlowNode, FlowNodeDependency, FlowPort, LLMDescriptor } from './flow'; +import { FlowBlockConnection, FlowData, FlowNode, FlowNodeDependency, FlowPort, LLMDescriptor } from './flow'; import { BiasExecutionContext } from './bias-impact'; export type TaskExecutionStatus = 'CREATED' | 'READY' | 'RUNNING' | 'WAITING' | 'SUSPENDED' | 'SUCCESS' | 'ERROR' | 'CANCELLED'; @@ -31,9 +31,10 @@ export type TaskExecution = { interactionSimulationEnabled?: boolean; simulationAvailable?: boolean; interactionSimulationDescriptor?: LLMDescriptor; + flowSnapshot?: FlowData; stepConnections?: FlowBlockConnection[]; stepDependencies?: FlowNodeDependency[]; - requiredAuthorizations?: Record; + requiredAuthorizations?: Record | TaskExecutionAuthorizationRequirement[]; providedAuthorizations?: Record; missingAuthorizationKeys?: string[]; missingGlobalInputKeys?: string[]; @@ -68,10 +69,15 @@ export type TaskExecutionContext = { startTime?: number | null; endTime?: number | null; errors: Record; - warnings: Record; + warnings: Record | unknown[]; steps: Record; status: TaskExecutionStatus; waitingSteps: string[]; + authorizations?: Record; + executionVariables?: Record; + executionVariableDescriptors?: Record; + errorCodes?: Record; + outcomes?: unknown[]; }; export type TaskExecutionGlobalInputDescriptor = { @@ -86,11 +92,12 @@ export type TaskExecutionGlobalInputDescriptor = { export type TaskExecutionStep = { node?: FlowNode; id: string; - inputs: TaskExecutionStepInput[]; - outputs: TaskExecutionStepOutput[]; + inputs?: TaskExecutionStepInput[]; + outputs?: TaskExecutionStepOutput[]; result?: Record; status: StepStatus; - started: boolean; + started?: boolean; + skipReason?: string | null; simulated: boolean; }; diff --git a/src/app/services/blocks/blocks-call.ts b/src/app/services/blocks/blocks-call.ts index 90bca22..cd60d9d 100644 --- a/src/app/services/blocks/blocks-call.ts +++ b/src/app/services/blocks/blocks-call.ts @@ -177,6 +177,7 @@ export class BlocksCallService extends BlocksCallServiceBase { specificConfiguration, typeName, nodeFamily: 'block', + ...(value["capabilities"] == null ? {} : { capabilities: toNodeCapabilities(value["capabilities"]) }), biasAnnotations: Array.isArray(value["biasAnnotations"]) ? value["biasAnnotations"] as FlowBlock["biasAnnotations"] : [] diff --git a/src/app/services/containers/containers-call.ts b/src/app/services/containers/containers-call.ts index fb8b52d..9348aae 100644 --- a/src/app/services/containers/containers-call.ts +++ b/src/app/services/containers/containers-call.ts @@ -149,6 +149,7 @@ export class ContainersCallService extends ContainersCallServiceBase { specificConfiguration, typeName, nodeFamily: 'container', + ...(value["capabilities"] == null ? {} : { capabilities: toNodeCapabilities(value["capabilities"]) }), biasAnnotations: Array.isArray(value["biasAnnotations"]) ? value["biasAnnotations"] as FlowContainer["biasAnnotations"] : [] diff --git a/src/app/services/flows/flows.ts b/src/app/services/flows/flows.ts index 11851ff..6b884a5 100644 --- a/src/app/services/flows/flows.ts +++ b/src/app/services/flows/flows.ts @@ -2,7 +2,7 @@ import { Injectable, signal } from '@angular/core'; import { environment } from '@environment'; import { Flow } from '@models/flow'; import { FlowsCallServiceBase } from './flows-call.base'; -import { catchError, firstValueFrom, Observable, tap, throwError } from 'rxjs'; +import { catchError, firstValueFrom, Observable, of, tap, throwError } from 'rxjs'; @Injectable({ providedIn: 'root', @@ -34,6 +34,23 @@ export class FlowsService { return this.flows; } + getFlowById(flowId: string): Observable { + const cached = this._flows().find((flow) => flow.id === flowId); + if (cached) return of(cached); + + return this.flowsCallService.getFlowById(flowId).pipe( + tap((flow) => { + this._flows.update((flows) => { + const index = flows.findIndex((candidate) => candidate.id === flow.id); + if (index < 0) return [flow, ...flows]; + const next = [...flows]; + next[index] = flow; + return next; + }); + }) + ); + } + async refresh(force = false): Promise { if (this.loadingPromise && !force) { return this.loadingPromise; diff --git a/src/app/services/task-executions/task-executions-call.spec.ts b/src/app/services/task-executions/task-executions-call.spec.ts index e667de0..6a3081a 100644 --- a/src/app/services/task-executions/task-executions-call.spec.ts +++ b/src/app/services/task-executions/task-executions-call.spec.ts @@ -45,6 +45,133 @@ describe('TaskExecutionsCallService bias APIs', () => { afterEach(() => httpMock.verify()); + it('maps the execution flow snapshot, branched topology, bias annotations and node capabilities', async () => { + const result = firstValueFrom(service.retrieveAllTaskExecutions()); + const request = httpMock.expectOne(`${environment.apiUrl}/executions`); + request.flush([{ + id: 'execution-1', + name: 'Branched flow', + creationTime: 1, + flowId: 'flow-1', + context: { + inputs: {}, + result: {}, + errors: {}, + warnings: {}, + waitingSteps: [], + status: 'READY', + steps: {}, + connections: [ + { + id: 'branch-a', + sourceNodeId: 'decision', + sourceOutput: 'accepted', + targetNodeId: 'accepted-step', + targetInput: 'input' + } + ] + }, + flowSnapshot: { + blocks: [{ + id: 'decision', + name: 'Decision', + inputs: [{ name: 'input', type: 'ANY', multiple: false }], + outputs: [{ name: 'accepted', type: 'ANY', multiple: false }], + specificConfiguration: {}, + typeName: 'HumanDecisionBlock', + biasAnnotations: [{ id: 'bias-1', category: 'SELECTION_BIAS' }], + capabilities: { + visualRole: 'DECISION', + terminal: false, + biasAnnotationsAllowed: true, + allowsIncomingConnections: true, + allowsOutgoingConnections: true, + canDependOnOtherNodes: true, + canHaveDependentNodes: true + } + }], + containers: [], + connections: [{ + id: 'branch-a', + sourceId: 'decision', + sourceName: 'accepted', + targetId: 'accepted-step', + targetName: 'input' + }], + dependencies: [] + } + }]); + + const execution = await result; + expect(execution[0].flowSnapshot?.connections[0]).toEqual({ + id: 'branch-a', + sourceId: 'decision', + sourceName: 'accepted', + targetId: 'accepted-step', + targetName: 'input' + }); + expect(execution[0].flowSnapshot?.blocks[0].biasAnnotations).toEqual([ + { id: 'bias-1', category: 'SELECTION_BIAS' } + ]); + expect(execution[0].flowSnapshot?.blocks[0].capabilities?.visualRole).toBe('DECISION'); + expect(execution[0].stepConnections?.[0].sourceName).toBe('accepted'); + }); + + it('keeps the documented root topology and execution node metadata', async () => { + const result = firstValueFrom(service.retrieveAllTaskExecutions()); + const request = httpMock.expectOne(`${environment.apiUrl}/executions`); + request.flush([{ + id: 'execution-id', + name: 'test biased', + sourceFlowId: 'flow-id', + creationTime: 1784795504244, + requiredAuthorizations: [], + context: { + inputs: {}, + result: {}, + errors: {}, + warnings: [], + waitingSteps: [], + status: 'CREATED', + steps: { + decision: { + id: 'decision', + status: 'WAITING_FOR_INPUT', + skipReason: null, + simulated: false, + node: { + id: 'decision', + name: 'shortlist-decision', + typeName: 'HumanDecisionBlock', + userInteractive: true, + position: { x: 600, y: 160 }, + inputs: [{ name: 'input', type: 'TEXT', multiple: false }], + outputs: [ + { name: 'approve', type: 'TEXT', multiple: false }, + { name: 'reject', type: 'TEXT', multiple: false } + ], + biasAnnotations: [{ id: 'selection-risk', category: 'SELECTION_BIAS' }], + specificConfiguration: { name: 'shortlist-decision' } + } + } + } + }, + stepConnections: [ + { id: 'approve-edge', sourceId: 'decision', sourceName: 'approve', targetId: 'approved', targetName: 'input' }, + { id: 'reject-edge', sourceId: 'decision', sourceName: 'reject', targetId: 'rejected', targetName: 'input' } + ], + stepDependencies: [] + }]); + + const [execution] = await result; + const decision = execution.context.steps['decision']; + expect(execution.stepConnections?.map((connection) => connection.sourceName)).toEqual(['approve', 'reject']); + expect(execution.stepDependencies).toEqual([]); + expect(decision.node?.position).toEqual({ x: 600, y: 160 }); + expect(decision.node?.userInteractive).toBe(true); + expect(decision.node?.biasAnnotations?.[0].id).toBe('selection-risk'); + }); + it('starts an asynchronous impact experiment and maps the job response', async () => { const result = firstValueFrom(service.runBiasImpactExperiment('execution-1', 'step-1', { annotationIds: ['annotation-1'], diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts index b55f052..56a83cc 100644 --- a/src/app/services/task-executions/task-executions-call.ts +++ b/src/app/services/task-executions/task-executions-call.ts @@ -1,7 +1,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { inject } from '@angular/core'; import { environment } from '@environment'; -import { LLMDescriptor } from '@models/flow'; +import { FlowBlockConnection, FlowData, FlowNodeDependency, LLMDescriptor } from '@models/flow'; import { BiasDownstreamImpactEntry, BiasImpactExperimentRequest, @@ -247,9 +247,37 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { context['globalInputDescriptors'] ?? execution['globalInputDescriptors'] ); + const flowSnapshot = this.normalizeFlowSnapshot( + execution['flowSnapshot'] + ?? execution['flowData'] + ?? context['flowSnapshot'] + ?? context['flowData'] + ?? (execution['flow'] && typeof execution['flow'] === 'object' ? execution['flow'] : null) + ); + const rawStepConnections = + execution['stepConnections'] + ?? execution['connections'] + ?? context['stepConnections'] + ?? context['connections'] + ?? flowSnapshot?.connections; + const stepConnections = rawStepConnections === undefined + ? undefined + : this.normalizeStepConnections(rawStepConnections); + const rawStepDependencies = + execution['stepDependencies'] + ?? execution['dependencies'] + ?? context['stepDependencies'] + ?? context['dependencies'] + ?? flowSnapshot?.dependencies; + const stepDependencies = rawStepDependencies === undefined + ? undefined + : this.normalizeStepDependencies(rawStepDependencies); return { ...execution, + flowSnapshot, + stepConnections, + stepDependencies, context: { ...(context as TaskExecution['context']), globalInputs, @@ -258,6 +286,64 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { }; } + private normalizeFlowSnapshot(raw: unknown): FlowData | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const root = raw as Record; + const nested = root['flow'] && typeof root['flow'] === 'object' && !Array.isArray(root['flow']) + ? root['flow'] as Record + : root; + if (!Array.isArray(nested['blocks']) && !Array.isArray(nested['containers'])) return undefined; + const blocks = Array.isArray(nested['blocks']) + ? nested['blocks'].filter((node): node is FlowData['blocks'][number] => !!node && typeof node === 'object') + .map((node) => ({ ...node, nodeFamily: 'block' as const })) + : []; + const containers = Array.isArray(nested['containers']) + ? nested['containers'].filter((node): node is FlowData['containers'][number] => !!node && typeof node === 'object') + .map((node) => ({ ...node, nodeFamily: 'container' as const })) + : []; + + return { + blocks, + containers, + connections: this.normalizeStepConnections(nested['connections']), + dependencies: this.normalizeStepDependencies(nested['dependencies']), + globalInputs: Array.isArray(nested['globalInputs']) ? nested['globalInputs'] as FlowData['globalInputs'] : [], + lanes: Array.isArray(nested['lanes']) ? nested['lanes'] as FlowData['lanes'] : [] + }; + } + + private normalizeStepConnections(raw: unknown): FlowBlockConnection[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap((item, index) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const value = item as Record; + const sourceId = value['sourceId'] ?? value['sourceNodeId'] ?? value['sourceBlockId']; + const sourceName = value['sourceName'] ?? value['sourceOutput'] ?? value['outputName']; + const targetId = value['targetId'] ?? value['targetNodeId'] ?? value['targetBlockId']; + const targetName = value['targetName'] ?? value['targetInput'] ?? value['inputName']; + if ([sourceId, sourceName, targetId, targetName].some((part) => typeof part !== 'string' || !part)) return []; + return [{ + id: String(value['id'] ?? `${sourceId}:${sourceName}->${targetId}:${targetName}:${index}`), + sourceId: String(sourceId), + sourceName: String(sourceName), + targetId: String(targetId), + targetName: String(targetName) + }]; + }); + } + + private normalizeStepDependencies(raw: unknown): FlowNodeDependency[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const value = item as Record; + const sourceId = value['sourceId'] ?? value['sourceNodeId']; + const targetId = value['targetId'] ?? value['targetNodeId']; + if (typeof sourceId !== 'string' || !sourceId || typeof targetId !== 'string' || !targetId) return []; + return [{ sourceId, targetId }]; + }); + } + private biasImpactJobFromApi(raw: unknown): BiasImpactJob { const value = this.toRecord(raw); const status = this.toBiasImpactJobStatus(value['status']); diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css index 9dfe4c3..1155c1a 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.css +++ b/src/app/shared/nodes/task-step-node/task-step-node.css @@ -71,6 +71,55 @@ box-shadow: 0 0 0 3px rgba(180, 83, 9, 0.22), 0 10px 24px rgba(15, 23, 42, 0.12); } +.llm-node-metadata { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + margin-top: 5px; +} + +.llm-node-capability-badge, +.llm-node-bias-summary, +.llm-node-bias-capability, +.llm-node-skip-reason { + display: inline-flex; + align-items: center; + gap: 3px; + min-height: 18px; + padding: 2px 6px; + border: 1px solid rgba(148, 163, 184, 0.72); + border-radius: 999px; + background: rgba(255, 255, 255, 0.9); + color: #475569; + font-size: 9px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} + +.llm-node-bias-summary, +.llm-node-bias-capability { + border-color: #c4b5fd; + color: #6d28d9; + background: #f5f3ff; +} + +.llm-node-bias-summary-active { + border-color: #7c3aed; + background: #7c3aed; + color: #fff; +} + +.llm-node-skip-reason { + max-width: 190px; + overflow: hidden; + border-color: #cbd5e1; + background: #f1f5f9; + color: #475569; + text-overflow: ellipsis; +} + .llm-bias-canvas-badge-wrap { position: absolute; top: -10px; diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index 6c0d26f..e74ec13 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -74,6 +74,30 @@
{{ name }}
+ @if (hasMeasurableBiasAnnotations()) {