mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
refactor: Split up instance-ai confirmation endpoint DTO by action (#29179)
This commit is contained in:
parent
c28d501ba1
commit
f775604c25
|
|
@ -25,7 +25,11 @@ export type {
|
|||
AiGatewayUsageResponse,
|
||||
} from './ai/ai-gateway-usage-response.dto';
|
||||
|
||||
export { InstanceAiConfirmRequestDto } from './instance-ai/instance-ai-confirm-request.dto';
|
||||
export {
|
||||
InstanceAiConfirmRequestDto,
|
||||
type InstanceAiConfirmRequest,
|
||||
type InstanceAiConfirmRequestKind,
|
||||
} from './instance-ai/instance-ai-confirm-request.dto';
|
||||
export { InstanceAiFeedbackRequestDto } from './instance-ai/instance-ai-feedback-request.dto';
|
||||
export { InstanceAiRenameThreadRequestDto } from './instance-ai/instance-ai-rename-thread-request.dto';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
import {
|
||||
InstanceAiConfirmRequestDto,
|
||||
type InstanceAiConfirmRequest,
|
||||
} from '../instance-ai-confirm-request.dto';
|
||||
|
||||
/**
|
||||
* The shapes in this file mirror what each frontend call site sends (see
|
||||
* components/ and composables/useSetupActions.ts in editor-ui). If a call site
|
||||
* changes the body shape, its branch here must change too — that is the whole
|
||||
* point of keeping this test.
|
||||
*/
|
||||
|
||||
describe('InstanceAiConfirmRequestDto', () => {
|
||||
describe('accepts each frontend-sent payload shape', () => {
|
||||
const cases: Array<[label: string, payload: InstanceAiConfirmRequest]> = [
|
||||
// InstanceAiConfirmationPanel: handleConfirm / handleApproveAll / handlePlanApprove / handleTextSkip
|
||||
['approval approve (no input)', { kind: 'approval', approved: true }],
|
||||
['approval deny (no input)', { kind: 'approval', approved: false }],
|
||||
// InstanceAiConfirmationPanel: handleTextSubmit
|
||||
[
|
||||
'approval with userInput (text submit)',
|
||||
{ kind: 'approval', approved: true, userInput: 'some typed answer' },
|
||||
],
|
||||
// InstanceAiConfirmationPanel: handlePlanRequestChanges + AgentTimeline feedback
|
||||
[
|
||||
'approval deny with userInput (plan feedback)',
|
||||
{ kind: 'approval', approved: false, userInput: 'please revise step 3' },
|
||||
],
|
||||
// InstanceAiConfirmationPanel: handleQuestionsSubmit
|
||||
[
|
||||
'questions with mixed answers',
|
||||
{
|
||||
kind: 'questions',
|
||||
answers: [
|
||||
{ questionId: 'q1', selectedOptions: ['opt-a'] },
|
||||
{ questionId: 'q2', selectedOptions: ['opt-b', 'opt-c'], customText: 'extra' },
|
||||
{ questionId: 'q3', selectedOptions: [], skipped: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
// InstanceAiCredentialSetup: handleContinue
|
||||
[
|
||||
'credentialSelection with credential map',
|
||||
{
|
||||
kind: 'credentialSelection',
|
||||
credentials: { slackApi: 'cred-1', githubApi: 'cred-2' },
|
||||
},
|
||||
],
|
||||
// DomainAccessApproval: handleAction (primary path — with action)
|
||||
[
|
||||
'domainAccessApprove with allow_domain',
|
||||
{ kind: 'domainAccessApprove', domainAccessAction: 'allow_domain' },
|
||||
],
|
||||
[
|
||||
'domainAccessApprove with allow_once',
|
||||
{ kind: 'domainAccessApprove', domainAccessAction: 'allow_once' },
|
||||
],
|
||||
[
|
||||
'domainAccessApprove with allow_all',
|
||||
{ kind: 'domainAccessApprove', domainAccessAction: 'allow_all' },
|
||||
],
|
||||
// DomainAccessApproval: handleAction (deny path)
|
||||
['domainAccessDeny', { kind: 'domainAccessDeny' }],
|
||||
// confirmResourceDecision (store)
|
||||
[
|
||||
'resourceDecision with arbitrary decision token',
|
||||
{ kind: 'resourceDecision', resourceDecision: 'allowForSession' },
|
||||
],
|
||||
// useSetupActions: handleApply
|
||||
[
|
||||
'setupWorkflowApply (full payload)',
|
||||
{
|
||||
kind: 'setupWorkflowApply',
|
||||
nodeCredentials: {
|
||||
'Slack Node': { slackApi: 'cred-1' },
|
||||
'GitHub Node': { githubApi: 'cred-2' },
|
||||
},
|
||||
nodeParameters: {
|
||||
'Slack Node': { channel: '#general' },
|
||||
},
|
||||
},
|
||||
],
|
||||
['setupWorkflowApply (no node credentials)', { kind: 'setupWorkflowApply' }],
|
||||
// useSetupActions: handleTestTrigger
|
||||
[
|
||||
'setupWorkflowTestTrigger (with node credentials)',
|
||||
{
|
||||
kind: 'setupWorkflowTestTrigger',
|
||||
testTriggerNode: 'Webhook',
|
||||
nodeCredentials: { Webhook: { httpHeaderAuth: 'cred-3' } },
|
||||
nodeParameters: { Webhook: { path: '/trigger' } },
|
||||
},
|
||||
],
|
||||
[
|
||||
'setupWorkflowTestTrigger (minimal)',
|
||||
{ kind: 'setupWorkflowTestTrigger', testTriggerNode: 'Webhook' },
|
||||
],
|
||||
];
|
||||
|
||||
test.each(cases)('%s', (_label, payload) => {
|
||||
const result = InstanceAiConfirmRequestDto.safeParse(payload);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data).toEqual(payload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejects invalid payloads', () => {
|
||||
test('missing kind discriminator', () => {
|
||||
const result = InstanceAiConfirmRequestDto.safeParse({ approved: true });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('unknown kind', () => {
|
||||
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'bogus', approved: true });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('questions without answers array', () => {
|
||||
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'questions' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('credentialSelection without credentials map', () => {
|
||||
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'credentialSelection' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('resourceDecision without decision', () => {
|
||||
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'resourceDecision' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('setupWorkflowTestTrigger without testTriggerNode', () => {
|
||||
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'setupWorkflowTestTrigger' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('domainAccessAction must be a known value', () => {
|
||||
const result = InstanceAiConfirmRequestDto.safeParse({
|
||||
kind: 'domainAccessApprove',
|
||||
domainAccessAction: 'allow_never',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('domainAccessApprove without domainAccessAction', () => {
|
||||
const result = InstanceAiConfirmRequestDto.safeParse({ kind: 'domainAccessApprove' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,28 +1,92 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { domainAccessActionSchema } from '../../schemas/instance-ai.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
|
||||
export class InstanceAiConfirmRequestDto extends Z.class({
|
||||
/**
|
||||
* Plain approval/denial. Also carries optional `userInput` for:
|
||||
* - text-input confirmations (inputType='text')
|
||||
* - plan-review feedback accompanying approve/request-changes
|
||||
* - deferring/skipping credential or workflow setup wizards (`approved: false`)
|
||||
*/
|
||||
const approvalConfirmSchema = z.object({
|
||||
kind: z.literal('approval'),
|
||||
approved: z.boolean(),
|
||||
credentialId: z.string().optional(),
|
||||
credentials: z.record(z.string()).optional(),
|
||||
nodeCredentials: z.record(z.record(z.string())).optional(),
|
||||
autoSetup: z.object({ credentialType: z.string() }).optional(),
|
||||
userInput: z.string().optional(),
|
||||
domainAccessAction: domainAccessActionSchema.optional(),
|
||||
action: z.enum(['apply', 'test-trigger']).optional(),
|
||||
nodeParameters: z.record(z.record(z.unknown())).optional(),
|
||||
testTriggerNode: z.string().optional(),
|
||||
answers: z
|
||||
.array(
|
||||
z.object({
|
||||
questionId: z.string(),
|
||||
selectedOptions: z.array(z.string()),
|
||||
customText: z.string().optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
resourceDecision: z.string().optional(),
|
||||
}) {}
|
||||
});
|
||||
|
||||
/** Q&A wizard submission (inputType='questions'). */
|
||||
const questionsConfirmSchema = z.object({
|
||||
kind: z.literal('questions'),
|
||||
answers: z.array(
|
||||
z.object({
|
||||
questionId: z.string(),
|
||||
selectedOptions: z.array(z.string()),
|
||||
customText: z.string().optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/** Map of credential type → credential ID (e.g. `{ slackApi: 'cred-1', githubApi: 'cred-2' }`). */
|
||||
const credentialIdByTypeSchema = z.record(z.string());
|
||||
|
||||
const credentialSelectionConfirmSchema = z.object({
|
||||
kind: z.literal('credentialSelection'),
|
||||
credentials: credentialIdByTypeSchema,
|
||||
});
|
||||
|
||||
/** Domain-access approval — `domainAccessAction` carries which scope the user picked. */
|
||||
const domainAccessApproveSchema = z.object({
|
||||
kind: z.literal('domainAccessApprove'),
|
||||
domainAccessAction: domainAccessActionSchema,
|
||||
});
|
||||
|
||||
/** Domain-access denial — no further input. */
|
||||
const domainAccessDenySchema = z.object({
|
||||
kind: z.literal('domainAccessDeny'),
|
||||
});
|
||||
|
||||
/** Gateway resource-access decision (inputType='resource-decision'). Approval is implied.
|
||||
* `resourceDecision` is one of the opaque tokens listed in the request's `options[]` array
|
||||
* (e.g. `'denyOnce'`, `'allowOnce'`, `'allowForSession'`) — the daemon defines the vocabulary,
|
||||
* so we keep this as a string rather than a fixed enum. */
|
||||
const resourceDecisionConfirmSchema = z.object({
|
||||
kind: z.literal('resourceDecision'),
|
||||
resourceDecision: z.string(),
|
||||
});
|
||||
|
||||
/** Per-node credential map: `Record<nodeName, Record<credentialType, credentialId>>`. */
|
||||
const nodeCredentialsRecord = z.record(credentialIdByTypeSchema).optional();
|
||||
/** Per-node parameter map: `Record<nodeName, Record<paramName, value>>`. */
|
||||
const nodeParametersRecord = z.record(z.record(z.unknown())).optional();
|
||||
|
||||
/** Workflow-setup wizard: apply the chosen credentials/parameters. Approval is implied;
|
||||
* the service maps this to `action: 'apply'` for the underlying Mastra resume schema. */
|
||||
const setupWorkflowApplyConfirmSchema = z.object({
|
||||
kind: z.literal('setupWorkflowApply'),
|
||||
nodeCredentials: nodeCredentialsRecord,
|
||||
nodeParameters: nodeParametersRecord,
|
||||
});
|
||||
|
||||
/** Workflow-setup wizard: run a test-trigger against a specific node. Approval is implied;
|
||||
* the service maps this to `action: 'test-trigger'` for the underlying Mastra resume schema. */
|
||||
const setupWorkflowTestTriggerConfirmSchema = z.object({
|
||||
kind: z.literal('setupWorkflowTestTrigger'),
|
||||
testTriggerNode: z.string(),
|
||||
nodeCredentials: nodeCredentialsRecord,
|
||||
nodeParameters: nodeParametersRecord,
|
||||
});
|
||||
|
||||
export const InstanceAiConfirmRequestDto = z.discriminatedUnion('kind', [
|
||||
approvalConfirmSchema,
|
||||
questionsConfirmSchema,
|
||||
credentialSelectionConfirmSchema,
|
||||
domainAccessApproveSchema,
|
||||
domainAccessDenySchema,
|
||||
resourceDecisionConfirmSchema,
|
||||
setupWorkflowApplyConfirmSchema,
|
||||
setupWorkflowTestTriggerConfirmSchema,
|
||||
]);
|
||||
|
||||
export type InstanceAiConfirmRequest = z.infer<typeof InstanceAiConfirmRequestDto>;
|
||||
export type InstanceAiConfirmRequestKind = InstanceAiConfirmRequest['kind'];
|
||||
|
|
|
|||
|
|
@ -343,7 +343,6 @@ export type {
|
|||
InstanceAiEvent,
|
||||
InstanceAiAttachment,
|
||||
InstanceAiSendMessageResponse,
|
||||
InstanceAiConfirmResponse,
|
||||
InstanceAiToolCallState,
|
||||
InstanceAiAgentNode,
|
||||
InstanceAiTimelineEntry,
|
||||
|
|
|
|||
|
|
@ -601,28 +601,6 @@ export interface InstanceAiSendMessageResponse {
|
|||
runId: string;
|
||||
}
|
||||
|
||||
export interface InstanceAiConfirmResponse {
|
||||
approved: boolean;
|
||||
credentialId?: string;
|
||||
credentials?: Record<string, string>;
|
||||
/** Per-node credential assignments: `{ nodeName: { credType: credId } }`.
|
||||
* Preferred over `credentials` when present — enables card-scoped selection. */
|
||||
nodeCredentials?: Record<string, Record<string, string>>;
|
||||
autoSetup?: { credentialType: string };
|
||||
userInput?: string;
|
||||
domainAccessAction?: DomainAccessAction;
|
||||
resourceDecision?: string;
|
||||
action?: 'apply' | 'test-trigger';
|
||||
nodeParameters?: Record<string, Record<string, unknown>>;
|
||||
testTriggerNode?: string;
|
||||
answers?: Array<{
|
||||
questionId: string;
|
||||
selectedOptions: string[];
|
||||
customText?: string;
|
||||
skipped?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontend store types (shared so both sides agree on structure)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type {
|
||||
InstanceAiConfirmRequest,
|
||||
InstanceAiRichMessagesResponse,
|
||||
InstanceAiEvalExecutionResult,
|
||||
InstanceAiEvalSubAgentRequest,
|
||||
|
|
@ -121,16 +122,12 @@ export class N8nClient {
|
|||
/**
|
||||
* Confirm or reject an action requested by the agent.
|
||||
* POST /rest/instance-ai/confirm/:requestId
|
||||
* body: { approved, mockCredentials?, credentialId?, ... }
|
||||
* body: kind-tagged `InstanceAiConfirmRequest` discriminated union.
|
||||
*/
|
||||
async confirmAction(
|
||||
requestId: string,
|
||||
approved: boolean,
|
||||
options?: { mockCredentials?: boolean },
|
||||
): Promise<void> {
|
||||
async confirmAction(requestId: string, payload: InstanceAiConfirmRequest): Promise<void> {
|
||||
await this.fetch(`/rest/instance-ai/confirm/${requestId}`, {
|
||||
method: 'POST',
|
||||
body: { approved, ...options },
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
// LLM-mocked HTTP, checklist verification, and result aggregation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { InstanceAiEvalExecutionResult } from '@n8n/api-types';
|
||||
import type { InstanceAiConfirmRequest, InstanceAiEvalExecutionResult } from '@n8n/api-types';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { type EvalLogger } from './logger';
|
||||
|
|
@ -727,9 +727,7 @@ async function processConfirmationRequests(config: WaitConfig): Promise<void> {
|
|||
}
|
||||
|
||||
try {
|
||||
// Always offer mock credentials — the eval runner doesn't have real
|
||||
// credentials for most services, so tell Instance AI to use mock data
|
||||
await config.client.confirmAction(requestId, true, { mockCredentials: true });
|
||||
await config.client.confirmAction(requestId, buildAutoApprovePayload(event));
|
||||
config.approvedRequests.add(requestId);
|
||||
confirmationRetries.delete(requestId);
|
||||
} catch (error: unknown) {
|
||||
|
|
@ -742,6 +740,40 @@ async function processConfirmationRequests(config: WaitConfig): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/** Map a confirmation-request event to the most-permissive approval payload of the
|
||||
* matching kind. The eval runner has no real credentials and no human in the loop —
|
||||
* we just need a structurally-valid payload that lets the agent proceed. */
|
||||
function buildAutoApprovePayload(event: CapturedEvent): InstanceAiConfirmRequest {
|
||||
const payload = getNestedRecord(event.data, 'payload') ?? {};
|
||||
|
||||
if (getNestedRecord(payload, 'domainAccess')) {
|
||||
return { kind: 'domainAccessApprove', domainAccessAction: 'allow_all' };
|
||||
}
|
||||
|
||||
const resourceDecision = getNestedRecord(payload, 'resourceDecision');
|
||||
if (resourceDecision) {
|
||||
const options = Array.isArray(resourceDecision.options)
|
||||
? (resourceDecision.options as unknown[]).filter((o): o is string => typeof o === 'string')
|
||||
: [];
|
||||
const allowOption = options.find((o) => o.toLowerCase().includes('allow')) ?? options[0];
|
||||
return { kind: 'resourceDecision', resourceDecision: allowOption ?? 'allowOnce' };
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.setupRequests)) {
|
||||
return { kind: 'setupWorkflowApply' };
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.credentialRequests)) {
|
||||
return { kind: 'credentialSelection', credentials: {} };
|
||||
}
|
||||
|
||||
if (payload.inputType === 'questions') {
|
||||
return { kind: 'questions', answers: [] };
|
||||
}
|
||||
|
||||
return { kind: 'approval', approved: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -691,7 +691,7 @@ describe('RunStateRegistry', () => {
|
|||
|
||||
registry.registerPendingConfirmation('req-1', pending);
|
||||
|
||||
const data: ConfirmationData = { approved: true, credentialId: 'cred-1' };
|
||||
const data: ConfirmationData = { approved: true, userInput: 'looks good' };
|
||||
const result = registry.resolvePendingConfirmation('user-1', 'req-1', data);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
|
|
|||
|
|
@ -24,12 +24,16 @@ export interface SuspendedRunState<TUser = unknown> extends ActiveRunState {
|
|||
checkpoint?: { isCheckpointFollowUp: true; checkpointTaskId: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat confirmation payload consumed by Mastra tool `resumeSchema`s and sub-agent HITL.
|
||||
* The service layer constructs this from the typed `InstanceAiConfirmRequest` discriminated
|
||||
* union sent by the frontend — only one subset of fields is populated per call, matching
|
||||
* the confirmation kind that was originally requested.
|
||||
*/
|
||||
export interface ConfirmationData {
|
||||
approved: boolean;
|
||||
credentialId?: string;
|
||||
credentials?: Record<string, string>;
|
||||
nodeCredentials?: Record<string, Record<string, string>>;
|
||||
autoSetup?: { credentialType: string };
|
||||
userInput?: string;
|
||||
domainAccessAction?: string;
|
||||
action?: 'apply' | 'test-trigger';
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import type {
|
|||
InstanceAiAdminSettingsUpdateRequest,
|
||||
InstanceAiSendMessageRequest,
|
||||
InstanceAiCorrectTaskRequest,
|
||||
InstanceAiConfirmRequestDto,
|
||||
InstanceAiConfirmRequest,
|
||||
InstanceAiEnsureThreadRequest,
|
||||
InstanceAiThreadMessagesQuery,
|
||||
InstanceAiUserPreferencesUpdateRequest,
|
||||
|
|
@ -497,39 +497,34 @@ describe('InstanceAiController', () => {
|
|||
|
||||
it('should resolve confirmation', async () => {
|
||||
instanceAiService.resolveConfirmation.mockResolvedValue(true);
|
||||
const body = mock<InstanceAiConfirmRequestDto>({ approved: true });
|
||||
const body: InstanceAiConfirmRequest = { kind: 'approval', approved: true };
|
||||
const reqWithBody = { ...req, body } as AuthenticatedRequest;
|
||||
|
||||
const result = await controller.confirm(req, res, 'req-1', body);
|
||||
const result = await controller.confirm(reqWithBody, res, 'req-1');
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(instanceAiService.resolveConfirmation).toHaveBeenCalledWith(
|
||||
USER_ID,
|
||||
'req-1',
|
||||
expect.objectContaining({ approved: true }),
|
||||
);
|
||||
expect(instanceAiService.resolveConfirmation).toHaveBeenCalledWith(USER_ID, 'req-1', body);
|
||||
});
|
||||
|
||||
it('should pass resourceDecision through to resolveConfirmation', async () => {
|
||||
instanceAiService.resolveConfirmation.mockResolvedValue(true);
|
||||
const body = mock<InstanceAiConfirmRequestDto>({
|
||||
approved: true,
|
||||
const body: InstanceAiConfirmRequest = {
|
||||
kind: 'resourceDecision',
|
||||
resourceDecision: 'allowOnce',
|
||||
});
|
||||
};
|
||||
const reqWithBody = { ...req, body } as AuthenticatedRequest;
|
||||
|
||||
await controller.confirm(req, res, 'req-1', body);
|
||||
await controller.confirm(reqWithBody, res, 'req-1');
|
||||
|
||||
expect(instanceAiService.resolveConfirmation).toHaveBeenCalledWith(
|
||||
USER_ID,
|
||||
'req-1',
|
||||
expect.objectContaining({ resourceDecision: 'allowOnce' }),
|
||||
);
|
||||
expect(instanceAiService.resolveConfirmation).toHaveBeenCalledWith(USER_ID, 'req-1', body);
|
||||
});
|
||||
|
||||
it('should throw NotFoundError when confirmation not found', async () => {
|
||||
instanceAiService.resolveConfirmation.mockResolvedValue(false);
|
||||
const body = mock<InstanceAiConfirmRequestDto>({ approved: false });
|
||||
const body: InstanceAiConfirmRequest = { kind: 'approval', approved: false };
|
||||
const reqWithBody = { ...req, body } as AuthenticatedRequest;
|
||||
|
||||
await expect(controller.confirm(req, res, 'req-1', body)).rejects.toThrow(NotFoundError);
|
||||
await expect(controller.confirm(reqWithBody, res, 'req-1')).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -325,27 +325,21 @@ export class InstanceAiController {
|
|||
|
||||
@Post('/confirm/:requestId')
|
||||
@GlobalScope('instanceAi:message')
|
||||
async confirm(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('requestId') requestId: string,
|
||||
@Body body: InstanceAiConfirmRequestDto,
|
||||
) {
|
||||
async confirm(req: AuthenticatedRequest, _res: Response, @Param('requestId') requestId: string) {
|
||||
this.requireInstanceAiEnabled();
|
||||
const resolved = await this.instanceAiService.resolveConfirmation(req.user.id, requestId, {
|
||||
approved: body.approved,
|
||||
credentialId: body.credentialId,
|
||||
credentials: body.credentials,
|
||||
nodeCredentials: body.nodeCredentials,
|
||||
autoSetup: body.autoSetup,
|
||||
userInput: body.userInput,
|
||||
domainAccessAction: body.domainAccessAction,
|
||||
action: body.action,
|
||||
nodeParameters: body.nodeParameters,
|
||||
testTriggerNode: body.testTriggerNode,
|
||||
answers: body.answers,
|
||||
resourceDecision: body.resourceDecision,
|
||||
});
|
||||
|
||||
// Manual parse: `@Body` decorator can't resolve zod discriminated unions via reflection,
|
||||
// so validate the request body against the union schema directly.
|
||||
const parseResult = InstanceAiConfirmRequestDto.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new BadRequestError(parseResult.error.errors[0].message);
|
||||
}
|
||||
|
||||
const resolved = await this.instanceAiService.resolveConfirmation(
|
||||
req.user.id,
|
||||
requestId,
|
||||
parseResult.data,
|
||||
);
|
||||
if (!resolved) {
|
||||
throw new NotFoundError('Confirmation request not found or not authorized');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
applyBranchReadOnlyOverrides,
|
||||
buildProxyHeaders,
|
||||
type InstanceAiAttachment,
|
||||
type InstanceAiConfirmRequest,
|
||||
type InstanceAiEvent,
|
||||
type InstanceAiThreadStatusResponse,
|
||||
type InstanceAiGatewayCapabilities,
|
||||
|
|
@ -143,6 +144,45 @@ interface MessageTraceFinalization {
|
|||
error?: string;
|
||||
}
|
||||
|
||||
/** Collapse the frontend's typed confirmation union into the flat payload
|
||||
* consumed by Mastra tool resume schemas and sub-agent HITL. Only the fields
|
||||
* relevant to the submitted kind are populated — everything else stays undefined.
|
||||
*
|
||||
* Most kinds carry implicit approval (you wouldn't be submitting answers,
|
||||
* selected credentials, or a setup action otherwise) — only `approval` and
|
||||
* `domainAccessDeny` actually carry a denial path. */
|
||||
function toConfirmationData(request: InstanceAiConfirmRequest): ConfirmationData {
|
||||
switch (request.kind) {
|
||||
case 'approval':
|
||||
return { approved: request.approved, userInput: request.userInput };
|
||||
case 'domainAccessApprove':
|
||||
return { approved: true, domainAccessAction: request.domainAccessAction };
|
||||
case 'domainAccessDeny':
|
||||
return { approved: false };
|
||||
case 'questions':
|
||||
return { approved: true, answers: request.answers };
|
||||
case 'credentialSelection':
|
||||
return { approved: true, credentials: request.credentials };
|
||||
case 'resourceDecision':
|
||||
return { approved: true, resourceDecision: request.resourceDecision };
|
||||
case 'setupWorkflowApply':
|
||||
return {
|
||||
approved: true,
|
||||
action: 'apply',
|
||||
nodeCredentials: request.nodeCredentials,
|
||||
nodeParameters: request.nodeParameters,
|
||||
};
|
||||
case 'setupWorkflowTestTrigger':
|
||||
return {
|
||||
approved: true,
|
||||
action: 'test-trigger',
|
||||
testTriggerNode: request.testTriggerNode,
|
||||
nodeCredentials: request.nodeCredentials,
|
||||
nodeParameters: request.nodeParameters,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class InstanceAiService {
|
||||
private readonly mcpClientManager = new McpClientManager();
|
||||
|
|
@ -2562,8 +2602,10 @@ export class InstanceAiService {
|
|||
async resolveConfirmation(
|
||||
requestingUserId: string,
|
||||
requestId: string,
|
||||
data: ConfirmationData,
|
||||
request: InstanceAiConfirmRequest,
|
||||
): Promise<boolean> {
|
||||
const data = toConfirmationData(request);
|
||||
|
||||
if (this.runState.resolvePendingConfirmation(requestingUserId, requestId, data)) {
|
||||
this.logger.debug('Resolved pending confirmation (sub-agent HITL)', {
|
||||
requestId,
|
||||
|
|
@ -2614,9 +2656,7 @@ export class InstanceAiService {
|
|||
const credentialsPayload = data.nodeCredentials ?? data.credentials;
|
||||
const resumeData = {
|
||||
approved: data.approved,
|
||||
...(data.credentialId ? { credentialId: data.credentialId } : {}),
|
||||
...(credentialsPayload ? { credentials: credentialsPayload } : {}),
|
||||
...(data.autoSetup ? { autoSetup: data.autoSetup } : {}),
|
||||
...(data.userInput !== undefined ? { userInput: data.userInput } : {}),
|
||||
...(data.domainAccessAction ? { domainAccessAction: data.domainAccessAction } : {}),
|
||||
...(data.action ? { action: data.action } : {}),
|
||||
|
|
|
|||
|
|
@ -252,9 +252,12 @@ describe('InstanceAiCredentialSetup', () => {
|
|||
|
||||
// Auto-continue fires since all are selected
|
||||
expect(resolveSpy).toHaveBeenCalledWith('req-1', 'approved');
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', true, undefined, {
|
||||
type1: 'cred-123',
|
||||
type2: 'cred-123',
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', {
|
||||
kind: 'credentialSelection',
|
||||
credentials: {
|
||||
type1: 'cred-123',
|
||||
type2: 'cred-123',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -275,7 +278,7 @@ describe('InstanceAiCredentialSetup', () => {
|
|||
await userEvent.click(getByText('instanceAi.credential.deny'));
|
||||
|
||||
expect(resolveSpy).toHaveBeenCalledWith('req-1', 'deferred');
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', false);
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', { kind: 'approval', approved: false });
|
||||
});
|
||||
|
||||
it('auto-continues when single credential is selected', async () => {
|
||||
|
|
@ -293,7 +296,10 @@ describe('InstanceAiCredentialSetup', () => {
|
|||
// Select credential — auto-continue should fire
|
||||
await userEvent.click(getByTestId('credential-picker'));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', true, undefined, { type1: 'cred-123' });
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', {
|
||||
kind: 'credentialSelection',
|
||||
credentials: { type1: 'cred-123' },
|
||||
});
|
||||
expect(getByText('instanceAi.credential.allSelected')).toBeTruthy();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@ describe('InstanceAiWorkflowSetup', () => {
|
|||
await userEvent.click(getByTestId('instance-ai-workflow-setup-later'));
|
||||
|
||||
expect(resolveSpy).toHaveBeenCalledWith('req-1', 'deferred');
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', false);
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', { kind: 'approval', approved: false });
|
||||
expect(getByText('instanceAi.workflowSetup.deferred')).toBeTruthy();
|
||||
});
|
||||
|
||||
|
|
@ -276,13 +276,7 @@ describe('InstanceAiWorkflowSetup', () => {
|
|||
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ action: 'apply' }),
|
||||
expect.objectContaining({ kind: 'setupWorkflowApply' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -360,13 +354,7 @@ describe('InstanceAiWorkflowSetup', () => {
|
|||
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ action: 'apply' }),
|
||||
expect.objectContaining({ kind: 'setupWorkflowApply' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -405,13 +393,7 @@ describe('InstanceAiWorkflowSetup', () => {
|
|||
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ action: 'apply' }),
|
||||
expect.objectContaining({ kind: 'setupWorkflowApply' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -440,7 +422,7 @@ describe('InstanceAiWorkflowSetup', () => {
|
|||
await userEvent.click(getByTestId('instance-ai-workflow-setup-later'));
|
||||
|
||||
expect(resolveSpy).toHaveBeenCalledWith('req-1', 'deferred');
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', false);
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', { kind: 'approval', approved: false });
|
||||
expect(getByText('instanceAi.workflowSetup.deferred')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -578,7 +560,7 @@ describe('InstanceAiWorkflowSetup', () => {
|
|||
await userEvent.click(getByTestId('instance-ai-workflow-setup-later'));
|
||||
|
||||
// Should defer since there's only 1 card and no selection
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', false);
|
||||
expect(confirmSpy).toHaveBeenCalledWith('req-1', { kind: 'approval', approved: false });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -853,14 +835,8 @@ describe('InstanceAiWorkflowSetup', () => {
|
|||
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
action: 'apply',
|
||||
kind: 'setupWorkflowApply',
|
||||
nodeParameters: { [nodeName]: { channel: '#general' } },
|
||||
}),
|
||||
);
|
||||
|
|
@ -926,13 +902,7 @@ describe('InstanceAiWorkflowSetup', () => {
|
|||
await waitFor(() => {
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
'req-1',
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ action: 'apply' }),
|
||||
expect.objectContaining({ kind: 'setupWorkflowApply' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1040,53 +1040,27 @@ describe('useInstanceAiStore - gateway resource-decision confirmation', () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('confirmAction passes resourceDecision to postConfirmation', async () => {
|
||||
await store.confirmAction(
|
||||
'req-1',
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'allowOnce',
|
||||
);
|
||||
it('confirmAction passes resourceDecision payload through to postConfirmation', async () => {
|
||||
await store.confirmAction('req-1', {
|
||||
kind: 'resourceDecision',
|
||||
resourceDecision: 'allowOnce',
|
||||
});
|
||||
|
||||
expect(mockPostConfirmation).toHaveBeenCalledOnce();
|
||||
expect(mockPostConfirmation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'req-1',
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'allowOnce',
|
||||
);
|
||||
expect(mockPostConfirmation).toHaveBeenCalledWith(expect.anything(), 'req-1', {
|
||||
kind: 'resourceDecision',
|
||||
resourceDecision: 'allowOnce',
|
||||
});
|
||||
});
|
||||
|
||||
it('confirmResourceDecision calls postConfirmation with approved=true and the decision', async () => {
|
||||
it('confirmResourceDecision calls postConfirmation with the decision token', async () => {
|
||||
await store.confirmResourceDecision('req-2', 'allowForSession');
|
||||
|
||||
expect(mockPostConfirmation).toHaveBeenCalledOnce();
|
||||
expect(mockPostConfirmation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'req-2',
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'allowForSession',
|
||||
);
|
||||
expect(mockPostConfirmation).toHaveBeenCalledWith(expect.anything(), 'req-2', {
|
||||
kind: 'resourceDecision',
|
||||
resourceDecision: 'allowForSession',
|
||||
});
|
||||
});
|
||||
|
||||
it('confirmResourceDecision does not call postConfirmation when confirmAction throws', async () => {
|
||||
|
|
|
|||
|
|
@ -138,7 +138,11 @@ function handlePlanConfirm(tc: InstanceAiToolCallState, approved: boolean, feedb
|
|||
telemetry.track('User finished providing input', eventProps);
|
||||
|
||||
store.resolveConfirmation(requestId, approved ? 'approved' : 'denied');
|
||||
void store.confirmAction(requestId, approved, undefined, undefined, undefined, feedback);
|
||||
void store.confirmAction(requestId, {
|
||||
kind: 'approval',
|
||||
approved,
|
||||
...(feedback ? { userInput: feedback } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Find the latest plan-review confirmation from a planner child's submit-plan tool call.
|
||||
|
|
|
|||
|
|
@ -49,17 +49,14 @@ const dropdownItems = computed<Array<ActionDropdownItem<DomainAction>>>(() =>
|
|||
],
|
||||
);
|
||||
|
||||
function handleAction(approved: boolean, domainAccessAction?: string) {
|
||||
function handleAction(approved: boolean, domainAccessAction?: DomainAction) {
|
||||
resolved.value = true;
|
||||
store.resolveConfirmation(props.requestId, approved ? 'approved' : 'denied');
|
||||
void store.confirmAction(
|
||||
props.requestId,
|
||||
approved,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
domainAccessAction,
|
||||
approved && domainAccessAction
|
||||
? { kind: 'domainAccessApprove', domainAccessAction }
|
||||
: { kind: 'domainAccessDeny' },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -67,8 +64,18 @@ function onPrimaryClick() {
|
|||
handleAction(true, primaryAction.value);
|
||||
}
|
||||
|
||||
const DOMAIN_ACTIONS: readonly DomainAction[] = [
|
||||
'allow_once',
|
||||
'allow_domain',
|
||||
'allow_all',
|
||||
] as const;
|
||||
|
||||
function isDomainAction(value: string): value is DomainAction {
|
||||
return (DOMAIN_ACTIONS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function onDropdownSelect(action: string) {
|
||||
handleAction(true, action);
|
||||
if (isDomainAction(action)) handleAction(true, action);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ function handleConfirm(item: PendingConfirmationItem, approved: boolean) {
|
|||
[],
|
||||
);
|
||||
store.resolveConfirmation(conf.requestId, approved ? 'approved' : 'denied');
|
||||
void store.confirmAction(conf.requestId, approved);
|
||||
void store.confirmAction(conf.requestId, { kind: 'approval', approved });
|
||||
}
|
||||
|
||||
function handleApproveAll(items: PendingConfirmationItem[]) {
|
||||
|
|
@ -157,7 +157,7 @@ function handleApproveAll(items: PendingConfirmationItem[]) {
|
|||
[],
|
||||
);
|
||||
store.resolveConfirmation(conf.requestId, 'approved');
|
||||
void store.confirmAction(conf.requestId, true);
|
||||
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -178,7 +178,7 @@ function handleTextSubmit(conf: InstanceAiConfirmation) {
|
|||
[],
|
||||
);
|
||||
store.resolveConfirmation(conf.requestId, 'approved');
|
||||
void store.confirmAction(conf.requestId, true, undefined, undefined, undefined, value);
|
||||
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true, userInput: value });
|
||||
}
|
||||
|
||||
function handleTextSkip(conf: InstanceAiConfirmation) {
|
||||
|
|
@ -188,7 +188,7 @@ function handleTextSkip(conf: InstanceAiConfirmation) {
|
|||
[{ label: conf.message, question: conf.message, input_type: 'text', options: [] }],
|
||||
);
|
||||
store.resolveConfirmation(conf.requestId, 'deferred');
|
||||
void store.confirmAction(conf.requestId, false);
|
||||
void store.confirmAction(conf.requestId, { kind: 'approval', approved: false });
|
||||
}
|
||||
|
||||
function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAnswer[]) {
|
||||
|
|
@ -230,17 +230,7 @@ function handleQuestionsSubmit(conf: InstanceAiConfirmation, answers: QuestionAn
|
|||
}
|
||||
trackInputCompleted(conf, provided, skipped, { num_tasks: answers.length });
|
||||
store.resolveConfirmation(conf.requestId, 'approved');
|
||||
void store.confirmAction(
|
||||
conf.requestId,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
answers,
|
||||
);
|
||||
void store.confirmAction(conf.requestId, { kind: 'questions', answers });
|
||||
}
|
||||
|
||||
function handlePlanApprove(conf: InstanceAiConfirmation, numTasks: number) {
|
||||
|
|
@ -251,7 +241,7 @@ function handlePlanApprove(conf: InstanceAiConfirmation, numTasks: number) {
|
|||
{ num_tasks: numTasks },
|
||||
);
|
||||
store.resolveConfirmation(conf.requestId, 'approved');
|
||||
void store.confirmAction(conf.requestId, true);
|
||||
void store.confirmAction(conf.requestId, { kind: 'approval', approved: true });
|
||||
}
|
||||
|
||||
function handlePlanRequestChanges(
|
||||
|
|
@ -266,7 +256,11 @@ function handlePlanRequestChanges(
|
|||
{ num_tasks: numTasks, feedback },
|
||||
);
|
||||
store.resolveConfirmation(conf.requestId, 'denied');
|
||||
void store.confirmAction(conf.requestId, false, undefined, undefined, undefined, feedback);
|
||||
void store.confirmAction(conf.requestId, {
|
||||
kind: 'approval',
|
||||
approved: false,
|
||||
userInput: feedback,
|
||||
});
|
||||
}
|
||||
|
||||
/** True when every item in the group is a generic approval (not domain/cred/text). */
|
||||
|
|
|
|||
|
|
@ -279,7 +279,10 @@ async function handleContinue() {
|
|||
|
||||
isSubmitted.value = true;
|
||||
|
||||
const success = await store.confirmAction(props.requestId, true, undefined, credentials);
|
||||
const success = await store.confirmAction(props.requestId, {
|
||||
kind: 'credentialSelection',
|
||||
credentials,
|
||||
});
|
||||
if (success) {
|
||||
store.resolveConfirmation(props.requestId, 'approved');
|
||||
} else {
|
||||
|
|
@ -293,7 +296,10 @@ async function handleLater() {
|
|||
isSubmitted.value = true;
|
||||
isDeferred.value = true;
|
||||
|
||||
const success = await store.confirmAction(props.requestId, false);
|
||||
const success = await store.confirmAction(props.requestId, {
|
||||
kind: 'approval',
|
||||
approved: false,
|
||||
});
|
||||
if (success) {
|
||||
store.resolveConfirmation(props.requestId, 'deferred');
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -174,20 +174,11 @@ export function useSetupActions(deps: {
|
|||
isApplying.value = true;
|
||||
applyError.value = null;
|
||||
|
||||
const postSuccess = await deps.store.confirmAction(
|
||||
deps.requestId.value,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
action: 'apply',
|
||||
nodeCredentials,
|
||||
nodeParameters,
|
||||
},
|
||||
);
|
||||
const postSuccess = await deps.store.confirmAction(deps.requestId.value, {
|
||||
kind: 'setupWorkflowApply',
|
||||
nodeCredentials,
|
||||
nodeParameters,
|
||||
});
|
||||
|
||||
if (!postSuccess) {
|
||||
isApplying.value = false;
|
||||
|
|
@ -221,21 +212,12 @@ export function useSetupActions(deps: {
|
|||
|
||||
applyError.value = null;
|
||||
|
||||
const postSuccess = await deps.store.confirmAction(
|
||||
deps.requestId.value,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
action: 'test-trigger',
|
||||
testTriggerNode: nodeName,
|
||||
nodeCredentials,
|
||||
nodeParameters,
|
||||
},
|
||||
);
|
||||
const postSuccess = await deps.store.confirmAction(deps.requestId.value, {
|
||||
kind: 'setupWorkflowTestTrigger',
|
||||
testTriggerNode: nodeName,
|
||||
nodeCredentials,
|
||||
nodeParameters,
|
||||
});
|
||||
|
||||
if (!postSuccess) {
|
||||
applyError.value = 'Failed to send trigger test request. Try again.';
|
||||
|
|
@ -314,7 +296,10 @@ export function useSetupActions(deps: {
|
|||
isSubmitted.value = true;
|
||||
isDeferred.value = true;
|
||||
|
||||
const success = await deps.store.confirmAction(deps.requestId.value, false);
|
||||
const success = await deps.store.confirmAction(deps.requestId.value, {
|
||||
kind: 'approval',
|
||||
approved: false,
|
||||
});
|
||||
if (success) {
|
||||
deps.store.resolveConfirmation(deps.requestId.value, 'deferred');
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type {
|
|||
InstanceAiAttachment,
|
||||
InstanceAiEnsureThreadResponse,
|
||||
InstanceAiSendMessageResponse,
|
||||
InstanceAiConfirmResponse,
|
||||
InstanceAiConfirmRequest,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
/**
|
||||
|
|
@ -89,50 +89,14 @@ export async function postCancelTask(
|
|||
|
||||
/**
|
||||
* POST /instance-ai/confirm/:requestId -> 200 OK
|
||||
* Approve or deny a confirmation request (HITL).
|
||||
* Resolve a confirmation request (HITL). The request body is a discriminated
|
||||
* union on `kind`.
|
||||
*/
|
||||
export async function postConfirmation(
|
||||
context: IRestApiContext,
|
||||
requestId: string,
|
||||
approved: boolean,
|
||||
credentialId?: string,
|
||||
credentials?: Record<string, string>,
|
||||
autoSetup?: { credentialType: string },
|
||||
userInput?: string,
|
||||
domainAccessAction?: string,
|
||||
setupWorkflowData?: {
|
||||
action?: 'apply' | 'test-trigger';
|
||||
nodeCredentials?: Record<string, Record<string, string>>;
|
||||
nodeParameters?: Record<string, Record<string, unknown>>;
|
||||
testTriggerNode?: string;
|
||||
},
|
||||
answers?: InstanceAiConfirmResponse['answers'],
|
||||
resourceDecision?: string,
|
||||
payload: InstanceAiConfirmRequest,
|
||||
): Promise<void> {
|
||||
const payload: InstanceAiConfirmResponse = {
|
||||
approved,
|
||||
...(credentialId ? { credentialId } : {}),
|
||||
...(credentials ? { credentials } : {}),
|
||||
...(autoSetup ? { autoSetup } : {}),
|
||||
...(userInput !== undefined ? { userInput } : {}),
|
||||
...(domainAccessAction
|
||||
? {
|
||||
domainAccessAction: domainAccessAction as InstanceAiConfirmResponse['domainAccessAction'],
|
||||
}
|
||||
: {}),
|
||||
...(setupWorkflowData?.action ? { action: setupWorkflowData.action } : {}),
|
||||
...(setupWorkflowData?.nodeCredentials
|
||||
? { nodeCredentials: setupWorkflowData.nodeCredentials }
|
||||
: {}),
|
||||
...(setupWorkflowData?.nodeParameters
|
||||
? { nodeParameters: setupWorkflowData.nodeParameters }
|
||||
: {}),
|
||||
...(setupWorkflowData?.testTriggerNode
|
||||
? { testTriggerNode: setupWorkflowData.testTriggerNode }
|
||||
: {}),
|
||||
...(answers ? { answers } : {}),
|
||||
...(resourceDecision ? { resourceDecision } : {}),
|
||||
};
|
||||
await makeRestApiRequest(context, 'POST', `/instance-ai/confirm/${requestId}`, payload);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
isSafeObjectKey,
|
||||
UNLIMITED_CREDITS,
|
||||
type InstanceAiConfirmation,
|
||||
type InstanceAiConfirmResponse,
|
||||
type InstanceAiConfirmRequest,
|
||||
} from '@n8n/api-types';
|
||||
import {
|
||||
ensureThread,
|
||||
|
|
@ -871,35 +871,10 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
|||
|
||||
async function confirmAction(
|
||||
requestId: string,
|
||||
approved: boolean,
|
||||
credentialId?: string,
|
||||
credentials?: Record<string, string>,
|
||||
autoSetup?: { credentialType: string },
|
||||
userInput?: string,
|
||||
domainAccessAction?: string,
|
||||
setupWorkflowData?: {
|
||||
action?: 'apply' | 'test-trigger';
|
||||
nodeCredentials?: Record<string, Record<string, string>>;
|
||||
nodeParameters?: Record<string, Record<string, unknown>>;
|
||||
testTriggerNode?: string;
|
||||
},
|
||||
answers?: InstanceAiConfirmResponse['answers'],
|
||||
resourceDecision?: string,
|
||||
payload: InstanceAiConfirmRequest,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await postConfirmation(
|
||||
rootStore.restApiContext,
|
||||
requestId,
|
||||
approved,
|
||||
credentialId,
|
||||
credentials,
|
||||
autoSetup,
|
||||
userInput,
|
||||
domainAccessAction,
|
||||
setupWorkflowData,
|
||||
answers,
|
||||
resourceDecision,
|
||||
);
|
||||
await postConfirmation(rootStore.restApiContext, requestId, payload);
|
||||
return true;
|
||||
} catch {
|
||||
toast.showError(new Error('Failed to send confirmation. Try again.'), 'Confirmation failed');
|
||||
|
|
@ -909,18 +884,7 @@ export const useInstanceAiStore = defineStore('instanceAi', () => {
|
|||
|
||||
async function confirmResourceDecision(requestId: string, decision: string): Promise<void> {
|
||||
resolveConfirmation(requestId, 'approved');
|
||||
await confirmAction(
|
||||
requestId,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
decision,
|
||||
);
|
||||
await confirmAction(requestId, { kind: 'resourceDecision', resourceDecision: decision });
|
||||
}
|
||||
|
||||
function toggleResearchMode(): void {
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@ export type {
|
|||
InstanceAiThreadSummary,
|
||||
InstanceAiSSEConnectionState,
|
||||
InstanceAiSendMessageResponse,
|
||||
InstanceAiConfirmResponse,
|
||||
InstanceAiConfirmRequest,
|
||||
InstanceAiConfirmRequestKind,
|
||||
} from '@n8n/api-types';
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user