feat(execution): gate execution start on vault credentials
An execution cannot start until every requiredAuthorizations entry is satisfied. A key of the form LLMProvider::<provider>::authorization is answered with a vault secret id chosen from a picker filtered by that provider, or from a credential created on the spot; anything else keeps the literal-value panel it had. A provider key never falls through to the literal-value panel, whatever the provider catalog says or fails to say, so the API key itself can no longer be pasted as the authorization value. The catalog is therefore no longer part of the gate: an outstanding requirement blocks the start on its own, and a failed catalog read is a notice with a retry. The authorizations PUT answers with the recomputed execution, so its response replaces the execution in the store rather than triggering a list refresh, and the backend's 400 message reaches the user. A settled requirement stays on screen with the credential label and a change action, and a banner above the graph names the missing providers when the context aside is collapsed. The gate itself is now pure - buildAuthorizationGate and isExecutionStartable in execution-viewer.utils.ts - and covered by tests, since the component cannot be instantiated under the test environment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4a40a8aa2a
commit
c7cefaca96
|
|
@ -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<TaskExecution> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export class TaskExecutionsService {
|
|||
taskExecutionsCallService: TaskExecutionsCallServiceBase = new environment.taskExecutionsCallService();
|
||||
private initialized = false;
|
||||
private refreshInFlight = false;
|
||||
private refreshQueued = false;
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private _taskExecutions = signal<TaskExecution[]>([]);
|
||||
private _taskExecutionGroups = signal<TaskExecutionGroup[]>([]);
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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::<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,18 @@
|
|||
Execution suspended after service restart. Resume the execution to continue.
|
||||
</div>
|
||||
}
|
||||
@if (authorizationGate().missingProviders.length || authorizationGate().runtime.length) {
|
||||
<div class="mx-1 mt-1 flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
<span>
|
||||
@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.
|
||||
}
|
||||
</span>
|
||||
<button type="button" mat-stroked-button (click)="openAuthorizationPanel()">Choose credential</button>
|
||||
</div>
|
||||
}
|
||||
<div class="flex-1 min-h-0 flex gap-2 p-1">
|
||||
<div class="execution-graph-shell flex-1 border border-slate-200 rounded-md bg-slate-50 min-h-0 overflow-hidden">
|
||||
<div class="px-3 py-2 text-xs font-semibold text-slate-600 border-b border-slate-200 bg-white">Execution Graph (read-only)</div>
|
||||
|
|
@ -81,8 +93,8 @@
|
|||
<button
|
||||
type="button"
|
||||
class="execution-action-button execution-graph-action-button execution-action-button-play"
|
||||
matTooltip="Start execution"
|
||||
aria-label="Start execution"
|
||||
[matTooltip]="startExecutionTooltip()"
|
||||
[attr.aria-label]="startExecutionTooltip()"
|
||||
[disabled]="!canStartExecution() || startInProgress()"
|
||||
(click)="startExecution()">
|
||||
<mat-icon fontIcon="play_arrow"></mat-icon>
|
||||
|
|
@ -205,9 +217,99 @@
|
|||
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
@if (activeAsideTab() === 'inputs') {
|
||||
@if (llmProviderCapabilitiesLoading()) {
|
||||
<div class="m-3 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">Checking LLM provider credentials...</div>
|
||||
}
|
||||
@if (llmProviderCapabilitiesError(); as llmError) {
|
||||
<div class="m-3 flex items-center justify-between gap-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">
|
||||
<span>{{ llmError }}</span>
|
||||
<button type="button" mat-stroked-button (click)="retryLlmProviderCapabilities()">Retry</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (entry of settledVaultAuthorizations(); track entry.requirement.key) {
|
||||
<section class="m-3 flex items-center justify-between gap-3 rounded-md border border-emerald-200 bg-emerald-50 p-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-semibold text-slate-800">{{ entry.provider }} credential</div>
|
||||
<div class="mt-1 truncate text-[11px] text-slate-600">{{ providedCredentialLabel(entry) }}</div>
|
||||
</div>
|
||||
<button type="button" mat-stroked-button (click)="changeVaultAuthorization(entry)">Change</button>
|
||||
</section>
|
||||
}
|
||||
|
||||
@for (entry of pendingVaultAuthorizations(); track entry.requirement.key) {
|
||||
<section class="m-3 rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<div class="text-xs font-semibold text-slate-800">Vault credential for {{ entry.provider }}</div>
|
||||
<div class="mt-1 text-[11px] text-slate-600">Select an active credential to start the execution.</div>
|
||||
@if (entry.requiresCredential === false) {
|
||||
<div class="mt-1 text-[11px] text-amber-800">This provider is listed as not requiring a credential, but the execution is asking for one.</div>
|
||||
}
|
||||
<mat-form-field appearance="outline" class="mt-2 w-full">
|
||||
<mat-label>Credential</mat-label>
|
||||
<mat-select
|
||||
[value]="selectedLlmCredentialFor(entry)"
|
||||
[disabled]="llmCredentialLoadingFor(entry) || authorizationSavingFor(entry)"
|
||||
(selectionChange)="selectLlmCredential(entry, $event.value)">
|
||||
@for (credential of llmCredentialOptionsFor(entry); track credential.id) {
|
||||
<mat-option [value]="credential.id">
|
||||
{{ credential.label }}{{ credential.description ? ' — ' + credential.description : '' }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (llmCredentialLoadingFor(entry)) { <mat-hint>Loading credentials...</mat-hint> }
|
||||
@if (authorizationSavingFor(entry)) { <mat-hint>Saving credential...</mat-hint> }
|
||||
</mat-form-field>
|
||||
@if (llmCredentialErrorFor(entry); as credentialError) {
|
||||
<div class="mb-2 flex items-center justify-between gap-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">
|
||||
<span>{{ credentialError }}</span>
|
||||
<button type="button" mat-stroked-button (click)="retryLlmCredentials(entry)">Retry</button>
|
||||
</div>
|
||||
}
|
||||
@if (authorizationErrorFor(entry); as authorizationError) {
|
||||
<div class="mb-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">{{ authorizationError }}</div>
|
||||
}
|
||||
@if (!llmCredentialLoadingFor(entry) && !llmCredentialOptionsFor(entry).length && !llmCredentialErrorFor(entry)) {
|
||||
<div class="mt-2 text-xs text-amber-800">No compatible credentials available.</div>
|
||||
}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@if (!llmCredentialLoadingFor(entry)) {
|
||||
<button type="button" mat-stroked-button (click)="openVaultCredentialForm(entry.provider)">Add credential</button>
|
||||
}
|
||||
@if (editingAuthorizationKeys()[entry.requirement.key]) {
|
||||
<button type="button" mat-stroked-button (click)="cancelVaultAuthorizationChange(entry)">Cancel</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (credentialFormOpen()) {
|
||||
<form class="m-3 rounded-md border border-slate-200 bg-white p-3" (ngSubmit)="saveExecutionCredential()">
|
||||
<div class="mb-2 text-xs font-semibold text-slate-800">New {{ credentialFormProvider() }} credential</div>
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Label</mat-label>
|
||||
<input matInput name="executionCredentialLabel" required [ngModel]="credentialFormLabel()" (ngModelChange)="credentialFormLabel.set($event)" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>Description (optional)</mat-label>
|
||||
<input matInput name="executionCredentialDescription" [ngModel]="credentialFormDescription()" (ngModelChange)="credentialFormDescription.set($event)" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="w-full">
|
||||
<mat-label>API key</mat-label>
|
||||
<input matInput type="password" autocomplete="off" name="executionCredentialValue" required [ngModel]="credentialFormValue()" (ngModelChange)="credentialFormValue.set($event)" />
|
||||
<mat-hint>The value will not be shown after saving.</mat-hint>
|
||||
</mat-form-field>
|
||||
@if (credentialFormError(); as formError) {
|
||||
<div class="mb-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">{{ formError }}</div>
|
||||
}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="submit" mat-flat-button [disabled]="credentialFormSaving() || !credentialFormLabel().trim() || !credentialFormValue().trim()">{{ credentialFormSaving() ? 'Saving...' : 'Save credential' }}</button>
|
||||
<button type="button" mat-stroked-button [disabled]="credentialFormSaving()" (click)="closeVaultCredentialForm()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
<app-task-execution-inputs-panel
|
||||
[editableInputs]="editableInputs()"
|
||||
[authorizationRequirements]="missingAuthorizationRequirements()"
|
||||
[authorizationRequirements]="runtimeAuthorizationRequirements()"
|
||||
[authorizationValues]="pendingAuthorizationValues()"
|
||||
[savingAuthorizations]="savingAuthorizations()"
|
||||
[authorizationErrors]="authorizationErrors()"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, input, OnDestroy, signal, viewChild } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import {
|
||||
areFlowValueKindsCompatible,
|
||||
FlowBlock,
|
||||
|
|
@ -23,6 +26,8 @@ import {
|
|||
TaskExecutionAuthorizationRequirement,
|
||||
TaskExecutionStep
|
||||
} from '@models/task-execution';
|
||||
import { ExecutionVaultCredential, LlmProviderCapability } from '@models/llm-provider';
|
||||
import { VaultSecret } from '@models/assistant';
|
||||
import {
|
||||
EditableExecutionInput,
|
||||
TaskExecutionInputsPanelComponent
|
||||
|
|
@ -38,6 +43,10 @@ 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,
|
||||
|
|
@ -46,7 +55,7 @@ import {
|
|||
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 { firstValueFrom, take } from 'rxjs';
|
||||
import { firstValueFrom, Observable, of, take, tap } from 'rxjs';
|
||||
import {
|
||||
ExecutionOutputEntry,
|
||||
ExecutionOutputGroup,
|
||||
|
|
@ -66,7 +75,6 @@ import {
|
|||
buildExecutionIntermediateInputs,
|
||||
buildExecutionIntermediateInputGroups,
|
||||
buildVisibleExecutionLogs,
|
||||
isInputSet,
|
||||
normalizeEditableInputValue,
|
||||
getExecutionInputValues,
|
||||
getExecutionOutputValues,
|
||||
|
|
@ -74,6 +82,10 @@ import {
|
|||
getConnectedOutputs,
|
||||
getExecutionErrors,
|
||||
getExecutionWarnings,
|
||||
AuthorizationGate,
|
||||
VaultAuthorizationEntry,
|
||||
buildAuthorizationGate,
|
||||
isExecutionStartable,
|
||||
} from './execution-viewer.utils';
|
||||
import {
|
||||
mergeExecutionStepNode,
|
||||
|
|
@ -83,7 +95,7 @@ import {
|
|||
|
||||
@Component({
|
||||
selector: 'app-task-execution-viewer',
|
||||
imports: [CommonModule, ReteEditor, TaskExecutionInputsPanelComponent, MatButtonModule, MatIconModule, MatTooltipModule, BiasImpactReportListComponent],
|
||||
imports: [CommonModule, FormsModule, ReteEditor, TaskExecutionInputsPanelComponent, MatButtonModule, MatIconModule, MatTooltipModule, MatFormFieldModule, MatSelectModule, BiasImpactReportListComponent],
|
||||
templateUrl: './task-execution-viewer.html',
|
||||
styleUrl: './task-execution-viewer.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
|
@ -97,6 +109,9 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
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);
|
||||
|
|
@ -122,6 +137,21 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
readonly pendingAuthorizationValues = signal<Record<string, string>>({});
|
||||
readonly savingAuthorizations = signal<Record<string, boolean>>({});
|
||||
readonly authorizationErrors = signal<Record<string, string>>({});
|
||||
readonly llmProviderCapabilities = signal<LlmProviderCapability[]>([]);
|
||||
readonly llmProviderCapabilitiesLoading = signal(false);
|
||||
readonly llmProviderCapabilitiesError = signal<string | null>(null);
|
||||
readonly llmCredentialOptions = signal<Record<string, ExecutionVaultCredential[]>>({});
|
||||
readonly llmCredentialLoading = signal<Record<string, boolean>>({});
|
||||
readonly llmCredentialErrors = signal<Record<string, string>>({});
|
||||
/** Satisfied requirements the user reopened to pick a different credential. */
|
||||
readonly editingAuthorizationKeys = signal<Record<string, boolean>>({});
|
||||
readonly credentialFormOpen = signal(false);
|
||||
readonly credentialFormProvider = signal('');
|
||||
readonly credentialFormLabel = signal('');
|
||||
readonly credentialFormDescription = signal('');
|
||||
readonly credentialFormValue = signal('');
|
||||
readonly credentialFormSaving = signal(false);
|
||||
readonly credentialFormError = signal<string | null>(null);
|
||||
readonly outputPreviewModal = signal<ExecutionOutputEntry | null>(null);
|
||||
readonly intermediateInputPreviewModal = signal<ExecutionIntermediateInputEntry | null>(null);
|
||||
readonly executionLogs = signal<ExecutionEventLogEntry[]>([]);
|
||||
|
|
@ -132,6 +162,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
private readonly logsScrollViewport = viewChild<ElementRef<HTMLDivElement>>('logsScrollViewport');
|
||||
private sourceFlowRequestVersion = 0;
|
||||
private readonly sourceFlowCache = new Map<string, FlowData>();
|
||||
private readonly requestedCredentialProviders = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
|
|
@ -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<TaskExecutionAuthorizationRequirement[]>(() => {
|
||||
const execution = this.execution();
|
||||
if (!execution?.requiredAuthorizations) return [];
|
||||
readonly authorizationGate = computed<AuthorizationGate>(() =>
|
||||
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<VaultAuthorizationEntry[]>(() => {
|
||||
const gate = this.authorizationGate();
|
||||
const editing = this.editingAuthorizationKeys();
|
||||
return [
|
||||
...gate.vault,
|
||||
...gate.satisfiedVault.filter((entry) => editing[entry.requirement.key])
|
||||
];
|
||||
});
|
||||
|
||||
readonly missingAuthorizationRequirements = computed<TaskExecutionAuthorizationRequirement[]>(() => {
|
||||
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<VaultAuthorizationEntry[]>(() => {
|
||||
const editing = this.editingAuthorizationKeys();
|
||||
return this.authorizationGate().satisfiedVault.filter((entry) => !editing[entry.requirement.key]);
|
||||
});
|
||||
|
||||
readonly runtimeAuthorizationRequirements = computed(() => this.authorizationGate().runtime);
|
||||
|
||||
readonly executionFlowData = computed<FlowData>(() => {
|
||||
const executionStatusGroup = getExecutionStatusGroup(this.execution()?.context.status);
|
||||
const contextInputs = this.execution()?.context.inputs ?? {};
|
||||
|
|
@ -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<ExecutionVaultCredential[]> {
|
||||
const key = provider.trim().toLowerCase();
|
||||
if (!key) return of([]);
|
||||
this.llmCredentialLoading.update((current) => ({ ...current, [key]: true }));
|
||||
return this.executionVaultCredentials.listForProvider(provider).pipe(
|
||||
take(1),
|
||||
tap({
|
||||
next: (credentials) => {
|
||||
this.llmCredentialOptions.update((current) => ({ ...current, [key]: credentials }));
|
||||
this.llmCredentialLoading.update((current) => ({ ...current, [key]: false }));
|
||||
},
|
||||
error: (error: unknown) => {
|
||||
this.llmCredentialLoading.update((current) => ({ ...current, [key]: false }));
|
||||
this.llmCredentialErrors.update((current) => ({ ...current, [key]: this.executionErrorMessage(error) }));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private executionErrorMessage(error: unknown): string {
|
||||
return extractHttpErrorMessage(error as any)
|
||||
?? (typeof (error as { message?: unknown })?.message === 'string'
|
||||
&& (error as { message: string }).message.trim()
|
||||
? (error as { message: string }).message
|
||||
: 'Unable to load or save the provider credential.');
|
||||
}
|
||||
|
||||
async simulateExecution() {
|
||||
const executionId = this.execution()?.id;
|
||||
if (!executionId || !this.canSimulateExecution()) return;
|
||||
|
|
@ -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'
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue