diff --git a/src/app/services/task-executions/task-executions-call.fake.ts b/src/app/services/task-executions/task-executions-call.fake.ts index 6ea7970..7fd1ed5 100644 --- a/src/app/services/task-executions/task-executions-call.fake.ts +++ b/src/app/services/task-executions/task-executions-call.fake.ts @@ -235,9 +235,17 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase id: '74ec477f-b04e-494c-80cc-968a40527bef', name: 'Test Flow', creationTime: 1772705504567, - requiredAuthorizations: {}, + requiredAuthorizations: { + 'LLMProvider::testProvider::authorization': { + key: 'LLMProvider::testProvider::authorization', + provider: 'testProvider', + fieldName: 'authorization', + description: 'Select a saved credential for testProvider.', + requiredBySteps: ['f91ec0f7-03e8-4208-89ac-bd9db46dca8c'] + } + }, providedAuthorizations: {}, - missingAuthorizationKeys: [], + missingAuthorizationKeys: ['LLMProvider::testProvider::authorization'], context: { inputs: { 'f91ec0f7-03e8-4208-89ac-bd9db46dca8c:name': 'marie curie' @@ -866,12 +874,31 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase value: string ): Observable { const execution = this.findExecution(executionId); - execution.providedAuthorizations = { - ...(execution.providedAuthorizations ?? {}), - [key]: value ? 'provided' : '' + const required = execution.requiredAuthorizations ?? {}; + const isRequired = Array.isArray(required) + ? required.some((requirement) => requirement.key === key) + : Object.prototype.hasOwnProperty.call(required, key); + + if (!isRequired || !value.trim()) { + return throwError(() => ({ + status: 400, + error: { detail: !value.trim() ? 'The credential is required.' : `This execution does not require ${key}.` } + })); + } + + // A real endpoint answers with a freshly serialized execution, so hand back a new + // object here too: signal consumers only react to a changed reference. + const updated: TaskExecution = { + ...execution, + providedAuthorizations: { + ...(execution.providedAuthorizations ?? {}), + [key]: value + }, + missingAuthorizationKeys: (execution.missingAuthorizationKeys ?? []).filter((item) => item !== key) }; - execution.missingAuthorizationKeys = (execution.missingAuthorizationKeys ?? []).filter((item) => item !== key); - return of(execution); + const index = this.data.findIndex((item) => item.id === executionId); + if (index >= 0) this.data[index] = updated; + return of(updated); } private createIsolatedReport(baselineExecutionId: string, stepId: string): BiasImpactReport { diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts index aa2a14b..51a82c2 100644 --- a/src/app/services/task-executions/task-executions.ts +++ b/src/app/services/task-executions/task-executions.ts @@ -36,6 +36,7 @@ export class TaskExecutionsService { taskExecutionsCallService: TaskExecutionsCallServiceBase = new environment.taskExecutionsCallService(); private initialized = false; private refreshInFlight = false; + private refreshQueued = false; private pollTimer: ReturnType | null = null; private _taskExecutions = signal([]); private _taskExecutionGroups = signal([]); @@ -59,12 +60,19 @@ export class TaskExecutionsService { } refresh() { - if (this.refreshInFlight) return; + if (this.refreshInFlight) { + this.refreshQueued = true; + return; + } this.refreshInFlight = true; this.taskExecutionsCallService.retrieveTaskExecutionGroups().pipe( finalize(() => { this.refreshInFlight = false; + if (this.refreshQueued) { + this.refreshQueued = false; + this.refresh(); + } }) ).subscribe((groups) => { const taskExecutions = this.flattenGroups(groups); @@ -337,8 +345,11 @@ export class TaskExecutionsService { provideAuthorization(executionId: string, key: string, value: string) { return this.withRefreshAndErrorHandling( - this.taskExecutionsCallService.provideAuthorization(executionId, key, value), - 'Provide authorization failed' + this.taskExecutionsCallService.provideAuthorization(executionId, key, value).pipe( + tap((execution) => this.replaceExecution(execution)) + ), + 'Provide authorization failed', + false ); } diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts index 77053c2..4843098 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts @@ -1,8 +1,10 @@ -import { TaskExecutionStep } from '@models/task-execution'; +import { TaskExecution, TaskExecutionStep } from '@models/task-execution'; import { + buildAuthorizationGate, buildVisibleExecutionLogs, getExecutionInputValues, - getExecutionOutputValues + getExecutionOutputValues, + isExecutionStartable } from './execution-viewer.utils'; describe('execution viewer runtime values', () => { @@ -57,3 +59,116 @@ describe('execution viewer runtime values', () => { })); }); }); + +describe('authorization gate', () => { + const geminiRequirement = { + key: 'LLMProvider::Gemini::authorization', + provider: 'Gemini', + fieldName: 'authorization', + description: 'Select a saved credential for Gemini.', + requiredBySteps: ['step-1'] + }; + + const headerRequirement = { + key: 'HTTPServerCall::step-2::authorization', + provider: 'HTTPServerCall', + fieldName: 'authorization', + description: 'Authorization header', + requiredBySteps: ['step-2'] + }; + + const capabilities = [ + { name: 'InternalOllama', requiresCredential: false }, + { name: 'Gemini', requiresCredential: true } + ]; + + const readyState = { capabilities, loading: false, failed: false }; + const failedState = { capabilities: [], loading: false, failed: true }; + + const execution = (overrides: Partial = {}): TaskExecution => ({ + id: 'execution-1', + name: 'Flow', + creationTime: 1, + requiredAuthorizations: [geminiRequirement], + providedAuthorizations: {}, + missingAuthorizationKeys: ['LLMProvider::Gemini::authorization'], + context: { + inputs: {}, + result: {}, + errors: {}, + warnings: {}, + steps: {}, + status: 'CREATED', + waitingSteps: [] + }, + ...overrides + }); + + it('blocks the start until the required credential is provided', () => { + const pending = execution(); + expect(isExecutionStartable(pending, buildAuthorizationGate(pending, readyState))).toBe(false); + + const provided = execution({ + providedAuthorizations: { 'LLMProvider::Gemini::authorization': 'vault-secret-1' }, + missingAuthorizationKeys: [] + }); + const gate = buildAuthorizationGate(provided, readyState); + + expect(gate.satisfied).toBe(true); + expect(gate.vault).toEqual([]); + expect(gate.satisfiedVault.map((entry) => entry.provider)).toEqual(['Gemini']); + expect(isExecutionStartable(provided, gate)).toBe(true); + }); + + it('keeps a provider key on the vault path even when the catalog is unavailable', () => { + const gate = buildAuthorizationGate(execution(), failedState); + + expect(gate.runtime).toEqual([]); + expect(gate.vault.map((entry) => entry.provider)).toEqual(['Gemini']); + expect(gate.vault[0].requiresCredential).toBeNull(); + expect(gate.missingProviders).toEqual(['Gemini']); + }); + + it('routes authorizations that are not provider credentials to the literal-value panel', () => { + const withHeader = execution({ + requiredAuthorizations: [geminiRequirement, headerRequirement], + missingAuthorizationKeys: [geminiRequirement.key, headerRequirement.key] + }); + const gate = buildAuthorizationGate(withHeader, readyState); + + expect(gate.runtime.map((requirement) => requirement.key)).toEqual([headerRequirement.key]); + expect(gate.vault.map((entry) => entry.requirement.key)).toEqual([geminiRequirement.key]); + expect(gate.satisfied).toBe(false); + }); + + it('reads requirements given as a map exactly like the array form', () => { + const asMap = execution({ + requiredAuthorizations: { [geminiRequirement.key]: geminiRequirement } + }); + + expect(buildAuthorizationGate(asMap, readyState).vault.map((entry) => entry.requirement.key)) + .toEqual([geminiRequirement.key]); + }); + + it('does not start an execution whose inputs are still unset', () => { + const missingInput = execution({ + missingAuthorizationKeys: [], + context: { + ...execution().context, + globalInputDescriptors: { topic: { name: 'topic', kind: 'TEXT', value: null } }, + globalInputs: {} + } + }); + + expect(isExecutionStartable(missingInput, buildAuthorizationGate(missingInput, readyState))).toBe(false); + }); + + it('does not start an execution that has already left INIT', () => { + const running = execution({ + missingAuthorizationKeys: [], + context: { ...execution().context, status: 'RUNNING' } + }); + + expect(isExecutionStartable(running, buildAuthorizationGate(running, readyState))).toBe(false); + }); +}); diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts index 1437b68..068f890 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts @@ -13,8 +13,10 @@ import { getExecutionStatusGroup, getTaskExecutionStepNode, TaskExecution, + TaskExecutionAuthorizationRequirement, TaskExecutionStep, } from '@models/task-execution'; +import { LlmProviderCapability } from '@models/llm-provider'; export type ExecutionOutputEntry = { key: string; @@ -372,3 +374,164 @@ export function getExecutionWarnings(stepId: string, contextWarnings: unknown): } 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::::`, 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; +} diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index ae11353..2636c1f 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -73,6 +73,18 @@ Execution suspended after service restart. Resume the execution to continue. } + @if (authorizationGate().missingProviders.length || authorizationGate().runtime.length) { +
+ + @if (authorizationGate().missingProviders.length) { + This execution needs a Vault credential for {{ authorizationGate().missingProviders.join(', ') }} before it can start. + } @else { + This execution needs a provider authorization before it can start. + } + + +
+ }
Execution Graph (read-only)
@@ -81,8 +93,8 @@ +
+ } + + @for (entry of settledVaultAuthorizations(); track entry.requirement.key) { +
+
+
{{ entry.provider }} credential
+
{{ providedCredentialLabel(entry) }}
+
+ +
+ } + + @for (entry of pendingVaultAuthorizations(); track entry.requirement.key) { +
+
Vault credential for {{ entry.provider }}
+
Select an active credential to start the execution.
+ @if (entry.requiresCredential === false) { +
This provider is listed as not requiring a credential, but the execution is asking for one.
+ } + + Credential + + @for (credential of llmCredentialOptionsFor(entry); track credential.id) { + + {{ credential.label }}{{ credential.description ? ' — ' + credential.description : '' }} + + } + + @if (llmCredentialLoadingFor(entry)) { Loading credentials... } + @if (authorizationSavingFor(entry)) { Saving credential... } + + @if (llmCredentialErrorFor(entry); as credentialError) { +
+ {{ credentialError }} + +
+ } + @if (authorizationErrorFor(entry); as authorizationError) { +
{{ authorizationError }}
+ } + @if (!llmCredentialLoadingFor(entry) && !llmCredentialOptionsFor(entry).length && !llmCredentialErrorFor(entry)) { +
No compatible credentials available.
+ } +
+ @if (!llmCredentialLoadingFor(entry)) { + + } + @if (editingAuthorizationKeys()[entry.requirement.key]) { + + } +
+
+ } + + @if (credentialFormOpen()) { +
+
New {{ credentialFormProvider() }} credential
+ + Label + + + + Description (optional) + + + + API key + + The value will not be shown after saving. + + @if (credentialFormError(); as formError) { +
{{ formError }}
+ } +
+ + +
+
+ } >({}); 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 credentialFormOpen = signal(false); + readonly credentialFormProvider = signal(''); + readonly credentialFormLabel = signal(''); + readonly credentialFormDescription = signal(''); + readonly credentialFormValue = signal(''); + readonly credentialFormSaving = signal(false); + readonly credentialFormError = signal(null); readonly outputPreviewModal = signal(null); readonly intermediateInputPreviewModal = signal(null); readonly executionLogs = signal([]); @@ -132,6 +162,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { private readonly logsScrollViewport = viewChild>('logsScrollViewport'); private sourceFlowRequestVersion = 0; private readonly sourceFlowCache = new Map(); + private readonly requestedCredentialProviders = new Set(); constructor() { effect(() => { @@ -142,6 +173,14 @@ export class TaskExecutionViewerComponent implements OnDestroy { this.pendingAuthorizationValues.set({}); this.savingAuthorizations.set({}); this.authorizationErrors.set({}); + this.llmCredentialOptions.set({}); + this.llmCredentialLoading.set({}); + this.llmCredentialErrors.set({}); + this.editingAuthorizationKeys.set({}); + this.requestedCredentialProviders.clear(); + this.credentialFormOpen.set(false); + this.credentialFormError.set(null); + this.loadLlmProviderCapabilities(); this.activeAsideTab.set('inputs'); this.outputPreviewModal.set(null); this.intermediateInputPreviewModal.set(null); @@ -150,6 +189,14 @@ export class TaskExecutionViewerComponent implements OnDestroy { 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; @@ -315,22 +362,32 @@ export class TaskExecutionViewerComponent implements OnDestroy { Object.values(this.execution()?.context.steps ?? {}) ); - readonly authorizationRequirements = computed(() => { - const execution = this.execution(); - if (!execution?.requiredAuthorizations) return []; + readonly authorizationGate = computed(() => + buildAuthorizationGate(this.execution(), { + capabilities: this.llmProviderCapabilities(), + loading: this.llmProviderCapabilitiesLoading(), + failed: !!this.llmProviderCapabilitiesError() + }) + ); - const required = execution.requiredAuthorizations; - const entries = Array.isArray(required) ? required : Object.values(required); - return entries - .filter((entry): entry is TaskExecutionAuthorizationRequirement => !!entry && typeof entry.key === 'string') - .sort((a, b) => a.provider.localeCompare(b.provider) || a.key.localeCompare(b.key)); + /** 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]) + ]; }); - readonly missingAuthorizationRequirements = computed(() => { - const missingKeys = new Set(this.execution()?.missingAuthorizationKeys ?? []); - return this.authorizationRequirements().filter((requirement) => missingKeys.has(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 ?? {}; @@ -512,6 +569,13 @@ export class TaskExecutionViewerComponent implements OnDestroy { const status = String(target?.context.status ?? '').toUpperCase(); return !this.cancelInProgress() && (status === 'RUNNING' || status === 'WAITING'); }); + readonly startExecutionTooltip = computed(() => { + const gate = this.authorizationGate(); + if (gate.missingProviders.length) return `Missing provider credential: ${gate.missingProviders.join(', ')}`; + if (gate.runtime.length) return 'Missing provider authorization'; + return 'Start execution'; + }); + readonly cancelExecutionTooltip = computed(() => this.isSubflowExecution() ? 'Cancel parent execution' : 'Cancel execution' ); @@ -522,48 +586,9 @@ export class TaskExecutionViewerComponent implements OnDestroy { return !this.resumeInProgress() && status === 'SUSPENDED'; }); - readonly canStartExecution = computed(() => { - const execution = this.execution(); - if (!execution) return false; - if (this.isSubflowExecution()) return false; - if ((execution.missingAuthorizationKeys?.length ?? 0) > 0) return false; - - const statusGroup = getExecutionStatusGroup(execution.context.status); - if (statusGroup !== '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; - }); + readonly canStartExecution = computed(() => + !this.isSubflowExecution() && isExecutionStartable(this.execution(), this.authorizationGate()) + ); readonly canSimulateExecution = computed(() => { return !this.isSubflowExecution() @@ -666,6 +691,12 @@ export class TaskExecutionViewerComponent implements OnDestroy { 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); } @@ -733,6 +764,182 @@ export class TaskExecutionViewerComponent implements OnDestroy { }); } + llmCredentialOptionsFor(entry: VaultAuthorizationEntry): ExecutionVaultCredential[] { + return this.llmCredentialOptions()[entry.provider.toLowerCase()] ?? []; + } + + llmCredentialLoadingFor(entry: VaultAuthorizationEntry): boolean { + return this.llmCredentialLoading()[entry.provider.toLowerCase()] === true; + } + + llmCredentialErrorFor(entry: VaultAuthorizationEntry): string | null { + return this.llmCredentialErrors()[entry.provider.toLowerCase()] ?? null; + } + + selectedLlmCredentialFor(entry: VaultAuthorizationEntry): string { + return this.pendingAuthorizationValues()[entry.requirement.key] ?? ''; + } + + authorizationSavingFor(entry: VaultAuthorizationEntry): boolean { + return this.savingAuthorizations()[entry.requirement.key] === true; + } + + authorizationErrorFor(entry: VaultAuthorizationEntry): string | null { + return this.authorizationErrors()[entry.requirement.key] ?? null; + } + + /** Label of the credential currently answering a satisfied requirement, when it can be resolved. */ + providedCredentialLabel(entry: VaultAuthorizationEntry): string { + const provided = this.execution()?.providedAuthorizations?.[entry.requirement.key]; + const credentialId = typeof provided === 'string' ? provided : ''; + const match = this.llmCredentialOptionsFor(entry).find((item) => item.id === credentialId); + return match?.label ?? 'Credential provided'; + } + + selectLlmCredential(entry: VaultAuthorizationEntry, credentialId: string) { + if (!this.llmCredentialOptionsFor(entry).some((item) => item.id === credentialId)) return; + this.applyLlmCredential(entry, credentialId); + } + + changeVaultAuthorization(entry: VaultAuthorizationEntry) { + this.editingAuthorizationKeys.update((current) => ({ ...current, [entry.requirement.key]: true })); + this.reloadLlmCredentials(entry.provider); + } + + cancelVaultAuthorizationChange(entry: VaultAuthorizationEntry) { + this.editingAuthorizationKeys.update((current) => { + const next = { ...current }; + delete next[entry.requirement.key]; + return next; + }); + } + + retryLlmCredentials(entry: VaultAuthorizationEntry) { + this.reloadLlmCredentials(entry.provider); + } + + retryLlmProviderCapabilities() { + this.loadLlmProviderCapabilities(); + } + + private applyLlmCredential(entry: VaultAuthorizationEntry, credentialId: string) { + if (!credentialId) return; + this.onAuthorizationValueChange(entry.requirement, credentialId); + this.submitAuthorization(entry.requirement); + } + + openVaultCredentialForm(provider: string) { + this.credentialFormError.set(null); + this.credentialFormProvider.set(provider); + this.credentialFormLabel.set(''); + this.credentialFormDescription.set(''); + this.credentialFormValue.set(''); + this.credentialFormOpen.set(true); + } + + closeVaultCredentialForm() { + this.credentialFormOpen.set(false); + this.credentialFormValue.set(''); + this.credentialFormError.set(null); + } + + saveExecutionCredential() { + const provider = this.credentialFormProvider().trim(); + const label = this.credentialFormLabel().trim(); + const value = this.credentialFormValue(); + if (!provider || !label || !value.trim() || this.credentialFormSaving()) return; + this.credentialFormSaving.set(true); + this.vaultService.createSecret({ + provider, + label, + description: this.credentialFormDescription().trim() || undefined, + value + }).pipe(take(1)).subscribe({ + next: (credential) => { + this.credentialFormSaving.set(false); + this.credentialFormValue.set(''); + this.credentialFormOpen.set(false); + this.credentialFormError.set(null); + this.reloadLlmCredentials(provider); + + // The vault id is the same id the authorizations endpoint takes, so the new + // credential can answer the requirement without waiting for the listing. + if (!credential.active) return; + const entry = this.pendingVaultAuthorizations().find((item) => + item.provider.toLowerCase() === provider.toLowerCase() + ); + if (entry) this.applyLlmCredential(entry, credential.id); + }, + error: (error) => { + this.credentialFormSaving.set(false); + this.credentialFormValue.set(''); + this.credentialFormError.set(this.executionErrorMessage(error)); + } + }); + } + + private loadLlmProviderCapabilities() { + if (!this.execution()?.id) return; + this.llmProviderCapabilitiesLoading.set(true); + this.llmProviderCapabilitiesError.set(null); + this.llmProviderService.listCapabilities().pipe(take(1)).subscribe({ + next: (capabilities) => { + this.llmProviderCapabilities.set(capabilities); + this.llmProviderCapabilitiesLoading.set(false); + }, + error: (error) => { + this.llmProviderCapabilitiesLoading.set(false); + this.llmProviderCapabilitiesError.set(this.executionErrorMessage(error)); + } + }); + } + + private loadLlmCredentialsOnce(provider: string) { + const key = provider.trim().toLowerCase(); + if (!key || this.requestedCredentialProviders.has(key)) return; + this.requestedCredentialProviders.add(key); + this.loadLlmCredentials(provider).subscribe(); + } + + private reloadLlmCredentials(provider: string) { + const key = provider.trim().toLowerCase(); + if (!key) return; + this.requestedCredentialProviders.add(key); + this.llmCredentialErrors.update((current) => { + const next = { ...current }; + delete next[key]; + return next; + }); + this.loadLlmCredentials(provider).subscribe(); + } + + private loadLlmCredentials(provider: string): Observable { + 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; @@ -875,6 +1082,8 @@ export class TaskExecutionViewerComponent implements OnDestroy { 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) => { @@ -882,9 +1091,17 @@ export class TaskExecutionViewerComponent implements OnDestroy { delete next[requirement.key]; return next; }); + this.editingAuthorizationKeys.update((current) => { + const next = { ...current }; + delete next[requirement.key]; + return next; + }); this.clearAuthorizationSaving(requirement.key); }, - error: () => this.setAuthorizationError(requirement.key, 'Failed to save authorization') + error: (error: unknown) => this.setAuthorizationError( + requirement.key, + extractHttpErrorMessage(error as any) ?? 'Failed to save authorization' + ) }); }