@open-mercato/ai-assistant 0.9.1-develop.7498.1.08d233a6cc → 0.9.1-develop.7513.1.ae2715553f
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/ai_assistant/api/ai/actions/[id]/cancel/route.js +2 -2
- package/dist/modules/ai_assistant/api/ai/actions/[id]/cancel/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/actions/[id]/confirm/route.js +2 -1
- package/dist/modules/ai_assistant/api/ai/actions/[id]/confirm/route.js.map +2 -2
- package/dist/modules/ai_assistant/api/ai/actions/[id]/route.js +2 -1
- package/dist/modules/ai_assistant/api/ai/actions/[id]/route.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/ai_assistant/api/ai/actions/[id]/__tests__/route.test.ts +29 -12
- package/src/modules/ai_assistant/api/ai/actions/[id]/cancel/__tests__/route.test.ts +33 -16
- package/src/modules/ai_assistant/api/ai/actions/[id]/cancel/route.ts +2 -6
- package/src/modules/ai_assistant/api/ai/actions/[id]/confirm/__tests__/route.test.ts +32 -16
- package/src/modules/ai_assistant/api/ai/actions/[id]/confirm/route.ts +2 -5
- package/src/modules/ai_assistant/api/ai/actions/[id]/route.ts +2 -5
|
@@ -11,7 +11,7 @@ import { executePendingActionCancel } from "../../../../../lib/pending-action-ca
|
|
|
11
11
|
const logger = createLogger("ai_assistant");
|
|
12
12
|
const REQUIRED_FEATURE = "ai_assistant.view";
|
|
13
13
|
const idParamSchema = z.object({
|
|
14
|
-
id: z.string().trim().
|
|
14
|
+
id: z.string().trim().pipe(z.guid("id must be a UUID"))
|
|
15
15
|
});
|
|
16
16
|
const bodySchema = z.object({
|
|
17
17
|
reason: z.string().max(500, "reason must be at most 500 characters").optional()
|
|
@@ -32,7 +32,7 @@ const openApi = {
|
|
|
32
32
|
}
|
|
33
33
|
],
|
|
34
34
|
errors: [
|
|
35
|
-
{ status: 400, description: "
|
|
35
|
+
{ status: 400, description: "The `:id` path parameter is not a UUID, or the cancel request body is invalid (unknown field, reason exceeds 500 chars, wrong type)." },
|
|
36
36
|
{ status: 401, description: "Unauthenticated caller." },
|
|
37
37
|
{ status: 403, description: "Caller lacks `ai_assistant.view`." },
|
|
38
38
|
{ status: 404, description: "Pending action not found in the caller scope." },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../src/modules/ai_assistant/api/ai/actions/%5Bid%5D/cancel/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { AiPendingActionRepository } from '../../../../../data/repositories/AiPendingActionRepository'\nimport { hasRequiredFeatures } from '../../../../../lib/auth'\nimport { serializePendingActionForClient } from '../../../../../lib/pending-action-client'\nimport { checkStatusAndExpiry } from '../../../../../lib/pending-action-recheck'\nimport { executePendingActionCancel } from '../../../../../lib/pending-action-cancel'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * POST `/api/ai/actions/:id/cancel` \u2014 mutation approval gate cancel\n * endpoint (spec \u00A79.4, Step 5.9).\n *\n * Siblings the Step 5.8 confirm route: flips `pending \u2192 cancelled` and\n * emits `ai.action.cancelled`. The tool handler is NEVER invoked.\n *\n * The route re-uses only the `status + expiry + tenant-scope` guards\n * from `pending-action-recheck.ts` \u2014 the agent / tool / attachment /\n * record-version guards are confirm-only. A caller may cancel even when\n * they'd be blocked from confirming; cancelling does not touch data.\n *\n * Idempotency: calling this endpoint twice on an already-`cancelled`\n * row returns 200 with the current row without re-emitting the event.\n */\n\nconst REQUIRED_FEATURE = 'ai_assistant.view'\n\nconst idParamSchema = z.object({\n id: z
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,oBAAsC;AAC/C,SAAS,SAAS;AAGlB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AAEvC,SAAS,iCAAiC;AAC1C,SAAS,2BAA2B;AACpC,SAAS,uCAAuC;AAChD,SAAS,4BAA4B;AACrC,SAAS,kCAAkC;AAE3C,MAAM,SAAS,aAAa,cAAc;AAkB1C,MAAM,mBAAmB;AAEzB,MAAM,gBAAgB,EAAE,OAAO;AAAA,EAC7B,IAAI,
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { AiPendingActionRepository } from '../../../../../data/repositories/AiPendingActionRepository'\nimport { hasRequiredFeatures } from '../../../../../lib/auth'\nimport { serializePendingActionForClient } from '../../../../../lib/pending-action-client'\nimport { checkStatusAndExpiry } from '../../../../../lib/pending-action-recheck'\nimport { executePendingActionCancel } from '../../../../../lib/pending-action-cancel'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * POST `/api/ai/actions/:id/cancel` \u2014 mutation approval gate cancel\n * endpoint (spec \u00A79.4, Step 5.9).\n *\n * Siblings the Step 5.8 confirm route: flips `pending \u2192 cancelled` and\n * emits `ai.action.cancelled`. The tool handler is NEVER invoked.\n *\n * The route re-uses only the `status + expiry + tenant-scope` guards\n * from `pending-action-recheck.ts` \u2014 the agent / tool / attachment /\n * record-version guards are confirm-only. A caller may cancel even when\n * they'd be blocked from confirming; cancelling does not touch data.\n *\n * Idempotency: calling this endpoint twice on an already-`cancelled`\n * row returns 200 with the current row without re-emitting the event.\n */\n\nconst REQUIRED_FEATURE = 'ai_assistant.view'\n\nconst idParamSchema = z.object({\n id: z.string().trim().pipe(z.guid('id must be a UUID')),\n})\n\nconst bodySchema = z\n .object({\n reason: z\n .string()\n .max(500, 'reason must be at most 500 characters')\n .optional(),\n })\n .strict()\n .optional()\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'Pending action (mutation approval gate) cancel',\n methods: {\n POST: {\n operationId: 'aiAssistantCancelPendingAction',\n summary: 'Cancel an AI pending action without executing the wrapped tool.',\n description:\n 'Flips a pending AI action from `pending` to `cancelled` and emits the ' +\n '`ai.action.cancelled` event. The tool handler is never invoked. Idempotent: ' +\n 'a second call on a row already in `cancelled` status returns 200 with the ' +\n 'current row without re-emitting the event. Rows whose `expiresAt` is in the ' +\n 'past are flipped to `expired` and returned as 409 `expired` so the client can ' +\n 'surface the TTL loss instead of silently masking it as a cancellation.',\n responses: [\n {\n status: 200,\n description: 'Cancellation complete (or idempotent replay); body includes the serialized pending action with status `cancelled`.',\n mediaType: 'application/json',\n },\n ],\n errors: [\n { status: 400, description: 'The `:id` path parameter is not a UUID, or the cancel request body is invalid (unknown field, reason exceeds 500 chars, wrong type).' },\n { status: 401, description: 'Unauthenticated caller.' },\n { status: 403, description: 'Caller lacks `ai_assistant.view`.' },\n { status: 404, description: 'Pending action not found in the caller scope.' },\n { status: 409, description: 'Pending action is not in `pending` status (already confirmed/failed/executing) or has expired.' },\n { status: 500, description: 'Unexpected server failure during cancel.' },\n ],\n },\n },\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nfunction jsonError(\n status: number,\n message: string,\n code: string,\n extra?: Record<string, unknown>,\n): NextResponse {\n return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })\n}\n\nasync function readRequestBody(req: NextRequest): Promise<unknown> {\n try {\n const text = await req.text()\n if (!text || text.trim().length === 0) return undefined\n return JSON.parse(text)\n } catch {\n return Symbol.for('ai_assistant.cancel.bad_json')\n }\n}\n\nexport async function POST(req: NextRequest, context: RouteContext): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return jsonError(401, 'Unauthorized', 'unauthenticated')\n }\n\n const rawParams = await context.params\n const paramResult = idParamSchema.safeParse(rawParams)\n if (!paramResult.success) {\n return jsonError(400, 'Invalid pending action id.', 'validation_error', {\n issues: paramResult.error.issues,\n })\n }\n const pendingActionId = paramResult.data.id\n\n const rawBody = await readRequestBody(req)\n if (rawBody === Symbol.for('ai_assistant.cancel.bad_json')) {\n return jsonError(400, 'Invalid JSON body.', 'validation_error')\n }\n const bodyResult = bodySchema.safeParse(rawBody)\n if (!bodyResult.success) {\n return jsonError(400, 'Invalid cancel body.', 'validation_error', {\n issues: bodyResult.error.issues,\n })\n }\n const parsedBody = bodyResult.data ?? {}\n\n try {\n const container = await createRequestContainer()\n const rbacService = container.resolve<RbacService>('rbacService')\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {\n return jsonError(403, `Caller lacks required feature \"${REQUIRED_FEATURE}\".`, 'forbidden')\n }\n\n if (!auth.tenantId) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n const em = container.resolve<EntityManager>('em')\n const repo = new AiPendingActionRepository(em)\n const row = await repo.getById(pendingActionId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n })\n if (!row) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n // Idempotent replay: second cancel on an already-cancelled row returns\n // 200 with the current row and does NOT emit a second event.\n if (row.status === 'cancelled') {\n return NextResponse.json({\n ok: true,\n pendingAction: serializePendingActionForClient(row),\n })\n }\n\n const statusCheck = checkStatusAndExpiry(row)\n if (!statusCheck.ok) {\n // Expired short-circuit: flip to `expired` + emit `ai.action.expired`\n // so Step 5.12 does not race to do it. Return 409 so the client\n // surfaces the TTL loss rather than silently cancelling.\n if (statusCheck.code === 'expired') {\n const cancelResult = await executePendingActionCancel({\n action: row,\n ctx: {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n container,\n },\n repo,\n })\n return jsonError(409, statusCheck.message, 'expired', {\n pendingAction: serializePendingActionForClient(cancelResult.row),\n })\n }\n return jsonError(statusCheck.status, statusCheck.message, statusCheck.code, statusCheck.extra)\n }\n\n const cancelResult = await executePendingActionCancel({\n action: row,\n ctx: {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n container,\n },\n reason: parsedBody.reason,\n repo,\n })\n\n if (cancelResult.status === 'expired') {\n return jsonError(\n 409,\n 'Pending action has expired. The model must re-propose the mutation.',\n 'expired',\n { pendingAction: serializePendingActionForClient(cancelResult.row) },\n )\n }\n\n return NextResponse.json({\n ok: true,\n pendingAction: serializePendingActionForClient(cancelResult.row),\n })\n } catch (error) {\n logger.error('AI Pending Action CANCEL \u2014 Failure', { err: error })\n return jsonError(\n 500,\n error instanceof Error ? error.message : 'Failed to cancel pending action.',\n 'cancel_internal_error',\n )\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["cancelResult"]
|
|
7
7
|
}
|
|
@@ -20,7 +20,7 @@ import { isKnownMutationPolicy } from "../../../../../lib/agent-policy.js";
|
|
|
20
20
|
const logger = createLogger("ai_assistant");
|
|
21
21
|
const REQUIRED_FEATURE = "ai_assistant.view";
|
|
22
22
|
const idParamSchema = z.object({
|
|
23
|
-
id: z.string().trim().
|
|
23
|
+
id: z.string().trim().pipe(z.guid("id must be a UUID"))
|
|
24
24
|
});
|
|
25
25
|
const openApi = {
|
|
26
26
|
tag: "AI Assistant",
|
|
@@ -38,6 +38,7 @@ const openApi = {
|
|
|
38
38
|
}
|
|
39
39
|
],
|
|
40
40
|
errors: [
|
|
41
|
+
{ status: 400, description: "The `:id` path parameter is not a UUID." },
|
|
41
42
|
{ status: 401, description: "Unauthenticated caller." },
|
|
42
43
|
{ status: 403, description: "Caller lacks `ai_assistant.view`, a required agent feature, tool whitelist, or accesses attachments outside their tenant." },
|
|
43
44
|
{ status: 404, description: "Pending action or agent not found in the caller scope." },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../src/modules/ai_assistant/api/ai/actions/%5Bid%5D/confirm/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { AiPendingActionRepository } from '../../../../../data/repositories/AiPendingActionRepository'\nimport { hasRequiredFeatures } from '../../../../../lib/auth'\nimport { serializePendingActionForClient } from '../../../../../lib/pending-action-client'\nimport { getAgent, loadAgentRegistry } from '../../../../../lib/agent-registry'\nimport { toolRegistry } from '../../../../../lib/tool-registry'\nimport { loadAllModuleTools } from '../../../../../lib/tool-loader'\nimport type { AiToolDefinition } from '../../../../../lib/types'\nimport {\n runPendingActionRechecks,\n type PendingActionRecheckResult,\n} from '../../../../../lib/pending-action-recheck'\nimport {\n executePendingActionConfirm,\n type PendingActionExecuteContext,\n} from '../../../../../lib/pending-action-executor'\nimport { AiAgentMutationPolicyOverrideRepository } from '../../../../../data/repositories/AiAgentMutationPolicyOverrideRepository'\nimport type { AiAgentMutationPolicy } from '../../../../../lib/ai-agent-definition'\nimport { isKnownMutationPolicy } from '../../../../../lib/agent-policy'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * POST `/api/ai/actions/:id/confirm` \u2014 mutation approval gate confirm\n * endpoint (spec \u00A79.4, Step 5.8).\n *\n * Invariant: every check from spec \u00A79.4 is re-run on the server before the\n * tool handler executes. The original model-proposed tool arguments are\n * re-parsed through the CURRENT tool `inputSchema` so that a schema\n * migration between propose and confirm surfaces as `schema_drift`\n * (412) instead of silently passing through invalid data.\n *\n * Idempotency: calling this endpoint twice on the same pending-action id\n * returns the prior execution result without re-invoking the handler.\n */\n\nconst REQUIRED_FEATURE = 'ai_assistant.view'\n\nconst idParamSchema = z.object({\n id: z
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,oBAAsC;AAC/C,SAAS,SAAS;AAGlB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AAEvC,SAAS,iCAAiC;AAC1C,SAAS,2BAA2B;AACpC,SAAS,uCAAuC;AAChD,SAAS,UAAU,yBAAyB;AAC5C,SAAS,oBAAoB;AAC7B,SAAS,0BAA0B;AAEnC;AAAA,EACE;AAAA,OAEK;AACP;AAAA,EACE;AAAA,OAEK;AACP,SAAS,+CAA+C;AAExD,SAAS,6BAA6B;AAEtC,MAAM,SAAS,aAAa,cAAc;AAgB1C,MAAM,mBAAmB;AAEzB,MAAM,gBAAgB,EAAE,OAAO;AAAA,EAC7B,IAAI,
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { AiPendingActionRepository } from '../../../../../data/repositories/AiPendingActionRepository'\nimport { hasRequiredFeatures } from '../../../../../lib/auth'\nimport { serializePendingActionForClient } from '../../../../../lib/pending-action-client'\nimport { getAgent, loadAgentRegistry } from '../../../../../lib/agent-registry'\nimport { toolRegistry } from '../../../../../lib/tool-registry'\nimport { loadAllModuleTools } from '../../../../../lib/tool-loader'\nimport type { AiToolDefinition } from '../../../../../lib/types'\nimport {\n runPendingActionRechecks,\n type PendingActionRecheckResult,\n} from '../../../../../lib/pending-action-recheck'\nimport {\n executePendingActionConfirm,\n type PendingActionExecuteContext,\n} from '../../../../../lib/pending-action-executor'\nimport { AiAgentMutationPolicyOverrideRepository } from '../../../../../data/repositories/AiAgentMutationPolicyOverrideRepository'\nimport type { AiAgentMutationPolicy } from '../../../../../lib/ai-agent-definition'\nimport { isKnownMutationPolicy } from '../../../../../lib/agent-policy'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * POST `/api/ai/actions/:id/confirm` \u2014 mutation approval gate confirm\n * endpoint (spec \u00A79.4, Step 5.8).\n *\n * Invariant: every check from spec \u00A79.4 is re-run on the server before the\n * tool handler executes. The original model-proposed tool arguments are\n * re-parsed through the CURRENT tool `inputSchema` so that a schema\n * migration between propose and confirm surfaces as `schema_drift`\n * (412) instead of silently passing through invalid data.\n *\n * Idempotency: calling this endpoint twice on the same pending-action id\n * returns the prior execution result without re-invoking the handler.\n */\n\nconst REQUIRED_FEATURE = 'ai_assistant.view'\n\nconst idParamSchema = z.object({\n id: z.string().trim().pipe(z.guid('id must be a UUID')),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'Pending action (mutation approval gate) confirm',\n methods: {\n POST: {\n operationId: 'aiAssistantConfirmPendingAction',\n summary: 'Confirm an AI pending action, re-running every server-side check before execution.',\n description:\n 'Re-verifies the full contract from spec \u00A79.4 (status, expiry, agent registration, ' +\n 'required features, mutation policy, tool whitelist, attachment tenant scope, record ' +\n 'version, and schema drift), flips the pending-action state machine to `executing`, ' +\n 'invokes the wrapped tool handler, and persists `executionResult`. Idempotent: a ' +\n 'second call on a row already in `confirmed` state returns the prior result without ' +\n 're-executing the handler.',\n responses: [\n {\n status: 200,\n description: 'Confirmation complete; body includes the serialized pending action and the mutation result.',\n mediaType: 'application/json',\n },\n ],\n errors: [\n { status: 400, description: 'The `:id` path parameter is not a UUID.' },\n { status: 401, description: 'Unauthenticated caller.' },\n { status: 403, description: 'Caller lacks `ai_assistant.view`, a required agent feature, tool whitelist, or accesses attachments outside their tenant.' },\n { status: 404, description: 'Pending action or agent not found in the caller scope.' },\n { status: 409, description: 'Pending action is not in `pending` status or has expired.' },\n { status: 412, description: 'Record version changed between propose and confirm, or the input schema no longer accepts the stored payload.' },\n { status: 500, description: 'Unexpected server failure during confirm.' },\n ],\n },\n },\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nfunction jsonError(\n status: number,\n message: string,\n code: string,\n extra?: Record<string, unknown>,\n): NextResponse {\n return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })\n}\n\nfunction fromRecheckFailure(result: PendingActionRecheckResult & { ok: false }): NextResponse {\n return jsonError(result.status, result.message, result.code, result.extra)\n}\n\nexport async function POST(req: NextRequest, context: RouteContext): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return jsonError(401, 'Unauthorized', 'unauthenticated')\n }\n\n const rawParams = await context.params\n const paramResult = idParamSchema.safeParse(rawParams)\n if (!paramResult.success) {\n return jsonError(400, 'Invalid pending action id.', 'validation_error', {\n issues: paramResult.error.issues,\n })\n }\n const pendingActionId = paramResult.data.id\n\n try {\n const container = await createRequestContainer()\n const rbacService = container.resolve<RbacService>('rbacService')\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {\n return jsonError(403, `Caller lacks required feature \"${REQUIRED_FEATURE}\".`, 'forbidden')\n }\n\n if (!auth.tenantId) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n const em = container.resolve<EntityManager>('em')\n const repo = new AiPendingActionRepository(em)\n const row = await repo.getById(pendingActionId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n })\n if (!row) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n // Idempotency short-circuit \u2014 a prior confirm already ran.\n if (row.status === 'confirmed' || row.status === 'failed') {\n const executionResult = row.executionResult ?? null\n return NextResponse.json({\n ok: row.status === 'confirmed',\n pendingAction: serializePendingActionForClient(row),\n mutationResult: executionResult,\n })\n }\n\n await loadAgentRegistry()\n await loadAllModuleTools()\n const agent = getAgent(row.agentId)\n const tool = toolRegistry.getTool(row.toolName) as AiToolDefinition | undefined\n\n const policyOverrideRepo = new AiAgentMutationPolicyOverrideRepository(em)\n const overrideRow = await policyOverrideRepo.get(row.agentId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n })\n const rawOverridePolicy = overrideRow?.mutationPolicy ?? null\n const mutationPolicyOverride: AiAgentMutationPolicy | null =\n rawOverridePolicy && isKnownMutationPolicy(rawOverridePolicy) ? rawOverridePolicy : null\n\n const recheckResult = await runPendingActionRechecks({\n action: row,\n agent,\n tool,\n ctx: {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n userFeatures: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n container,\n em,\n },\n mutationPolicyOverride,\n })\n if (!recheckResult.ok) {\n return fromRecheckFailure(recheckResult)\n }\n\n const executeCtx: PendingActionExecuteContext = {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n userFeatures: acl.features,\n isSuperAdmin: acl.isSuperAdmin,\n container,\n }\n\n const executed = await executePendingActionConfirm({\n action: row,\n agent: agent!,\n tool: tool!,\n ctx: executeCtx,\n repo,\n failedRecords: recheckResult.failedRecords ?? null,\n })\n\n return NextResponse.json({\n ok: executed.ok,\n pendingAction: serializePendingActionForClient(executed.action),\n mutationResult: executed.executionResult,\n })\n } catch (error) {\n logger.error('AI Pending Action CONFIRM \u2014 Failure', { err: error })\n return jsonError(\n 500,\n error instanceof Error ? error.message : 'Failed to confirm pending action.',\n 'confirm_internal_error',\n )\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -9,7 +9,7 @@ import { serializePendingActionForClient } from "../../../../lib/pending-action-
|
|
|
9
9
|
const logger = createLogger("ai_assistant");
|
|
10
10
|
const REQUIRED_FEATURE = "ai_assistant.view";
|
|
11
11
|
const idParamSchema = z.object({
|
|
12
|
-
id: z.string().trim().
|
|
12
|
+
id: z.string().trim().pipe(z.guid("id must be a UUID"))
|
|
13
13
|
});
|
|
14
14
|
const openApi = {
|
|
15
15
|
tag: "AI Assistant",
|
|
@@ -27,6 +27,7 @@ const openApi = {
|
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
29
|
errors: [
|
|
30
|
+
{ status: 400, description: "The `:id` path parameter is not a UUID." },
|
|
30
31
|
{ status: 401, description: "Unauthenticated caller." },
|
|
31
32
|
{ status: 403, description: "Caller lacks the `ai_assistant.view` feature." },
|
|
32
33
|
{ status: 404, description: "No pending action with the given id is accessible to the caller." },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/ai_assistant/api/ai/actions/%5Bid%5D/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { AiPendingActionRepository } from '../../../../data/repositories/AiPendingActionRepository'\nimport { hasRequiredFeatures } from '../../../../lib/auth'\nimport { serializePendingActionForClient } from '../../../../lib/pending-action-client'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * GET `/api/ai/actions/:id` \u2014 reconnect/polling endpoint for the Phase 3 WS-C\n * mutation approval gate (spec \u00A79, Step 5.7).\n *\n * When the chat UI bounces (reload, tab switch, SSE reconnect) it carries a\n * `pendingActionId` from an earlier `mutation-preview-card` UI part and needs\n * to re-hydrate the pending row's current state. This route is the authoritative\n * read-side; the confirm/cancel routes (Steps 5.8 / 5.9) use the same\n * whitelist serializer so the UI always sees the same shape.\n */\n\nconst REQUIRED_FEATURE = 'ai_assistant.view'\n\nconst idParamSchema = z.object({\n id: z
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,oBAAsC;AAC/C,SAAS,SAAS;AAGlB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AAEvC,SAAS,iCAAiC;AAC1C,SAAS,2BAA2B;AACpC,SAAS,uCAAuC;AAEhD,MAAM,SAAS,aAAa,cAAc;AAa1C,MAAM,mBAAmB;AAEzB,MAAM,gBAAgB,EAAE,OAAO;AAAA,EAC7B,IAAI,
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { NextResponse, type NextRequest } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { AiPendingActionRepository } from '../../../../data/repositories/AiPendingActionRepository'\nimport { hasRequiredFeatures } from '../../../../lib/auth'\nimport { serializePendingActionForClient } from '../../../../lib/pending-action-client'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * GET `/api/ai/actions/:id` \u2014 reconnect/polling endpoint for the Phase 3 WS-C\n * mutation approval gate (spec \u00A79, Step 5.7).\n *\n * When the chat UI bounces (reload, tab switch, SSE reconnect) it carries a\n * `pendingActionId` from an earlier `mutation-preview-card` UI part and needs\n * to re-hydrate the pending row's current state. This route is the authoritative\n * read-side; the confirm/cancel routes (Steps 5.8 / 5.9) use the same\n * whitelist serializer so the UI always sees the same shape.\n */\n\nconst REQUIRED_FEATURE = 'ai_assistant.view'\n\nconst idParamSchema = z.object({\n id: z.string().trim().pipe(z.guid('id must be a UUID')),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'AI Assistant',\n summary: 'Pending action (mutation approval gate) read-side',\n methods: {\n GET: {\n operationId: 'aiAssistantGetPendingAction',\n summary: 'Fetch the current state of an AI pending action by id.',\n description:\n 'Returns the tenant-scoped {@link AiPendingAction} addressed by `:id`. Powers the ' +\n 'chat UI reconnect/polling path: after a page reload or SSE reconnect the client ' +\n 'carries the `pendingActionId` from an earlier `mutation-preview-card` UI part and ' +\n 'calls this route to re-hydrate the card. Server-internal fields (`normalizedInput`, ' +\n '`createdByUserId`, `idempotencyKey`) are stripped by a whitelist serializer. ' +\n 'Enforces tenant/org scoping via the repository.',\n responses: [\n {\n status: 200,\n description:\n 'Serialized pending action. Never includes normalizedInput, createdByUserId, or idempotencyKey.',\n mediaType: 'application/json',\n },\n ],\n errors: [\n { status: 400, description: 'The `:id` path parameter is not a UUID.' },\n { status: 401, description: 'Unauthenticated caller.' },\n { status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },\n { status: 404, description: 'No pending action with the given id is accessible to the caller.' },\n { status: 500, description: 'Internal runtime failure.' },\n ],\n },\n },\n}\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: [REQUIRED_FEATURE] },\n}\n\ninterface RouteContext {\n params: Promise<{ id: string }>\n}\n\nfunction jsonError(\n status: number,\n message: string,\n code: string,\n extra?: Record<string, unknown>,\n): NextResponse {\n return NextResponse.json({ error: message, code, ...(extra ?? {}) }, { status })\n}\n\nexport async function GET(req: NextRequest, context: RouteContext): Promise<Response> {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return jsonError(401, 'Unauthorized', 'unauthenticated')\n }\n\n const rawParams = await context.params\n const paramResult = idParamSchema.safeParse(rawParams)\n if (!paramResult.success) {\n return jsonError(400, 'Invalid pending action id.', 'validation_error', {\n issues: paramResult.error.issues,\n })\n }\n const pendingActionId = paramResult.data.id\n\n try {\n const container = await createRequestContainer()\n const rbacService = container.resolve<RbacService>('rbacService')\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n if (!hasRequiredFeatures([REQUIRED_FEATURE], acl.features, acl.isSuperAdmin, rbacService)) {\n return jsonError(403, `Caller lacks required feature \"${REQUIRED_FEATURE}\".`, 'forbidden')\n }\n\n if (!auth.tenantId) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n const em = container.resolve<EntityManager>('em')\n const repo = new AiPendingActionRepository(em)\n const row = await repo.getById(pendingActionId, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId ?? null,\n userId: auth.sub,\n })\n\n if (!row) {\n return jsonError(\n 404,\n `No pending action \"${pendingActionId}\" accessible to the caller.`,\n 'pending_action_not_found',\n )\n }\n\n return NextResponse.json(serializePendingActionForClient(row))\n } catch (error) {\n logger.error('AI Pending Action GET \u2014 Failure', { err: error })\n return jsonError(\n 500,\n error instanceof Error ? error.message : 'Failed to load pending action.',\n 'internal_error',\n )\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ai-assistant",
|
|
3
|
-
"version": "0.9.1-develop.
|
|
3
|
+
"version": "0.9.1-develop.7513.1.ae2715553f",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -100,16 +100,16 @@
|
|
|
100
100
|
"zod-to-json-schema": "^3.25.2"
|
|
101
101
|
},
|
|
102
102
|
"peerDependencies": {
|
|
103
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
104
|
-
"@open-mercato/ui": "0.9.1-develop.
|
|
103
|
+
"@open-mercato/shared": "0.9.1-develop.7513.1.ae2715553f",
|
|
104
|
+
"@open-mercato/ui": "0.9.1-develop.7513.1.ae2715553f",
|
|
105
105
|
"react": "^19.0.0",
|
|
106
106
|
"react-dom": "^19.0.0",
|
|
107
107
|
"zod": ">=3.23.0"
|
|
108
108
|
},
|
|
109
109
|
"devDependencies": {
|
|
110
|
-
"@open-mercato/cli": "0.9.1-develop.
|
|
111
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
112
|
-
"@open-mercato/ui": "0.9.1-develop.
|
|
110
|
+
"@open-mercato/cli": "0.9.1-develop.7513.1.ae2715553f",
|
|
111
|
+
"@open-mercato/shared": "0.9.1-develop.7513.1.ae2715553f",
|
|
112
|
+
"@open-mercato/ui": "0.9.1-develop.7513.1.ae2715553f",
|
|
113
113
|
"@types/react": "^19.2.18",
|
|
114
114
|
"@types/react-dom": "^19.2.5",
|
|
115
115
|
"react": "19.2.8",
|
|
@@ -19,8 +19,12 @@ jest.mock('../../../../../data/repositories/AiPendingActionRepository', () => ({
|
|
|
19
19
|
|
|
20
20
|
import { GET } from '../route'
|
|
21
21
|
|
|
22
|
+
const PENDING_ACTION_ID = '3b7e1f2a-6c4d-4a9e-8f1b-2c3d4e5f6a7b'
|
|
23
|
+
const MISSING_PENDING_ACTION_ID = '5e2b8c71-9d4a-4f3e-8b6c-7a1d2e3f4b5c'
|
|
24
|
+
const OTHER_TENANT_PENDING_ACTION_ID = '7f3c9a40-2b1d-4e6f-9a8b-1c2d3e4f5a6b'
|
|
25
|
+
|
|
22
26
|
function buildRequest(): Request {
|
|
23
|
-
return new Request(
|
|
27
|
+
return new Request(`http://localhost/api/ai_assistant/ai/actions/${PENDING_ACTION_ID}`, {
|
|
24
28
|
method: 'GET',
|
|
25
29
|
headers: { 'content-type': 'application/json' },
|
|
26
30
|
})
|
|
@@ -32,7 +36,7 @@ function buildContext(id: string) {
|
|
|
32
36
|
|
|
33
37
|
function makeRow(overrides: Record<string, unknown> = {}) {
|
|
34
38
|
return {
|
|
35
|
-
id:
|
|
39
|
+
id: PENDING_ACTION_ID,
|
|
36
40
|
agentId: 'catalog.merchandising_assistant',
|
|
37
41
|
toolName: 'catalog.update_product',
|
|
38
42
|
status: 'pending',
|
|
@@ -99,7 +103,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
|
|
|
99
103
|
it('returns 401 when unauthenticated', async () => {
|
|
100
104
|
authMock.mockResolvedValueOnce(null)
|
|
101
105
|
|
|
102
|
-
const response = await GET(buildRequest() as any, buildContext(
|
|
106
|
+
const response = await GET(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
103
107
|
|
|
104
108
|
expect(response.status).toBe(401)
|
|
105
109
|
// 401 envelope does include `code: 'unauthenticated'` in this module's
|
|
@@ -113,11 +117,11 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
|
|
|
113
117
|
it('happy path: tenant-matching id returns 200 + serialized row', async () => {
|
|
114
118
|
repoGetByIdMock.mockResolvedValueOnce(makeRow())
|
|
115
119
|
|
|
116
|
-
const response = await GET(buildRequest() as any, buildContext(
|
|
120
|
+
const response = await GET(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
117
121
|
|
|
118
122
|
expect(response.status).toBe(200)
|
|
119
123
|
const body = await response.json()
|
|
120
|
-
expect(body.id).toBe(
|
|
124
|
+
expect(body.id).toBe(PENDING_ACTION_ID)
|
|
121
125
|
expect(body.agentId).toBe('catalog.merchandising_assistant')
|
|
122
126
|
expect(body.status).toBe('pending')
|
|
123
127
|
expect(body.toolName).toBe('catalog.update_product')
|
|
@@ -130,7 +134,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
|
|
|
130
134
|
|
|
131
135
|
// Repository was called with the tenant/org scope from the auth context.
|
|
132
136
|
expect(repoGetByIdMock).toHaveBeenCalledTimes(1)
|
|
133
|
-
expect(repoGetByIdMock).toHaveBeenCalledWith(
|
|
137
|
+
expect(repoGetByIdMock).toHaveBeenCalledWith(PENDING_ACTION_ID, {
|
|
134
138
|
tenantId: 'tenant-1',
|
|
135
139
|
organizationId: 'org-1',
|
|
136
140
|
userId: 'user-1',
|
|
@@ -142,7 +146,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
|
|
|
142
146
|
// to a different tenant/org.
|
|
143
147
|
repoGetByIdMock.mockResolvedValueOnce(null)
|
|
144
148
|
|
|
145
|
-
const response = await GET(buildRequest() as any, buildContext(
|
|
149
|
+
const response = await GET(buildRequest() as any, buildContext(OTHER_TENANT_PENDING_ACTION_ID))
|
|
146
150
|
|
|
147
151
|
expect(response.status).toBe(404)
|
|
148
152
|
const body = await response.json()
|
|
@@ -152,7 +156,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
|
|
|
152
156
|
it('returns 404 when the id is unknown (no row)', async () => {
|
|
153
157
|
repoGetByIdMock.mockResolvedValueOnce(null)
|
|
154
158
|
|
|
155
|
-
const response = await GET(buildRequest() as any, buildContext(
|
|
159
|
+
const response = await GET(buildRequest() as any, buildContext(MISSING_PENDING_ACTION_ID))
|
|
156
160
|
|
|
157
161
|
expect(response.status).toBe(404)
|
|
158
162
|
const body = await response.json()
|
|
@@ -165,7 +169,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
|
|
|
165
169
|
isSuperAdmin: false,
|
|
166
170
|
})
|
|
167
171
|
|
|
168
|
-
const response = await GET(buildRequest() as any, buildContext(
|
|
172
|
+
const response = await GET(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
169
173
|
|
|
170
174
|
expect(response.status).toBe(403)
|
|
171
175
|
const body = await response.json()
|
|
@@ -176,7 +180,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
|
|
|
176
180
|
it('internal fields are never present in the response body', async () => {
|
|
177
181
|
repoGetByIdMock.mockResolvedValueOnce(makeRow())
|
|
178
182
|
|
|
179
|
-
const response = await GET(buildRequest() as any, buildContext(
|
|
183
|
+
const response = await GET(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
180
184
|
|
|
181
185
|
expect(response.status).toBe(200)
|
|
182
186
|
const body = await response.json()
|
|
@@ -195,6 +199,19 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
|
|
|
195
199
|
expect(body.code).toBe('validation_error')
|
|
196
200
|
})
|
|
197
201
|
|
|
202
|
+
it.each([
|
|
203
|
+
['a non-UUID string', 'not-a-uuid'],
|
|
204
|
+
['a 128-character string', 'a'.repeat(128)],
|
|
205
|
+
])('returns 400 validation_error without querying the database for %s', async (_label, id) => {
|
|
206
|
+
const response = await GET(buildRequest() as any, buildContext(id))
|
|
207
|
+
|
|
208
|
+
expect(response.status).toBe(400)
|
|
209
|
+
const body = await response.json()
|
|
210
|
+
expect(body.code).toBe('validation_error')
|
|
211
|
+
expect(JSON.stringify(body)).not.toContain('invalid input syntax')
|
|
212
|
+
expect(repoGetByIdMock).not.toHaveBeenCalled()
|
|
213
|
+
})
|
|
214
|
+
|
|
198
215
|
it('returns 404 when the caller has no tenant scope', async () => {
|
|
199
216
|
authMock.mockResolvedValueOnce({
|
|
200
217
|
sub: 'user-1',
|
|
@@ -202,7 +219,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
|
|
|
202
219
|
orgId: null,
|
|
203
220
|
})
|
|
204
221
|
|
|
205
|
-
const response = await GET(buildRequest() as any, buildContext(
|
|
222
|
+
const response = await GET(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
206
223
|
|
|
207
224
|
expect(response.status).toBe(404)
|
|
208
225
|
const body = await response.json()
|
|
@@ -213,7 +230,7 @@ describe('GET /api/ai/actions/:id route (Step 5.7)', () => {
|
|
|
213
230
|
it('returns 500 when the repository throws', async () => {
|
|
214
231
|
repoGetByIdMock.mockRejectedValueOnce(new Error('db down'))
|
|
215
232
|
|
|
216
|
-
const response = await GET(buildRequest() as any, buildContext(
|
|
233
|
+
const response = await GET(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
217
234
|
|
|
218
235
|
expect(response.status).toBe(500)
|
|
219
236
|
const body = await response.json()
|
|
@@ -23,6 +23,9 @@ jest.mock('../../../../../../data/repositories/AiPendingActionRepository', () =>
|
|
|
23
23
|
import { setGlobalEventBus } from '@open-mercato/shared/modules/events'
|
|
24
24
|
import { POST } from '../route'
|
|
25
25
|
|
|
26
|
+
const PENDING_ACTION_ID = '3b7e1f2a-6c4d-4a9e-8f1b-2c3d4e5f6a7b'
|
|
27
|
+
const MISSING_PENDING_ACTION_ID = '5e2b8c71-9d4a-4f3e-8b6c-7a1d2e3f4b5c'
|
|
28
|
+
|
|
26
29
|
function buildRequest(body?: unknown): Request {
|
|
27
30
|
const init: RequestInit = {
|
|
28
31
|
method: 'POST',
|
|
@@ -31,7 +34,7 @@ function buildRequest(body?: unknown): Request {
|
|
|
31
34
|
if (body !== undefined) {
|
|
32
35
|
init.body = typeof body === 'string' ? body : JSON.stringify(body)
|
|
33
36
|
}
|
|
34
|
-
return new Request(
|
|
37
|
+
return new Request(`http://localhost/api/ai_assistant/ai/actions/${PENDING_ACTION_ID}/cancel`, init)
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
function buildContext(id: string) {
|
|
@@ -40,7 +43,7 @@ function buildContext(id: string) {
|
|
|
40
43
|
|
|
41
44
|
function makeRow(overrides: Record<string, unknown> = {}) {
|
|
42
45
|
return {
|
|
43
|
-
id:
|
|
46
|
+
id: PENDING_ACTION_ID,
|
|
44
47
|
tenantId: 'tenant-1',
|
|
45
48
|
organizationId: 'org-1',
|
|
46
49
|
agentId: 'catalog.merchandising_assistant',
|
|
@@ -127,14 +130,14 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
|
|
|
127
130
|
|
|
128
131
|
it('returns 401 when unauthenticated', async () => {
|
|
129
132
|
authMock.mockResolvedValueOnce(null)
|
|
130
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
133
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
131
134
|
expect(response.status).toBe(401)
|
|
132
135
|
})
|
|
133
136
|
|
|
134
137
|
it('happy path: pending → cancelled returns 200 with pendingAction.status === cancelled', async () => {
|
|
135
138
|
repoGetByIdMock.mockResolvedValueOnce(makeRow())
|
|
136
139
|
|
|
137
|
-
const response = await POST(buildRequest({ reason: 'Wrong price' }) as any, buildContext(
|
|
140
|
+
const response = await POST(buildRequest({ reason: 'Wrong price' }) as any, buildContext(PENDING_ACTION_ID))
|
|
138
141
|
expect(response.status).toBe(200)
|
|
139
142
|
const body = await response.json()
|
|
140
143
|
expect(body.ok).toBe(true)
|
|
@@ -159,7 +162,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
|
|
|
159
162
|
})
|
|
160
163
|
repoGetByIdMock.mockResolvedValueOnce(cancelledRow)
|
|
161
164
|
|
|
162
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
165
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
163
166
|
expect(response.status).toBe(200)
|
|
164
167
|
const body = await response.json()
|
|
165
168
|
expect(body.ok).toBe(true)
|
|
@@ -172,7 +175,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
|
|
|
172
175
|
const expiredRow = makeRow({ expiresAt: new Date('2020-01-01T00:00:00.000Z') })
|
|
173
176
|
repoGetByIdMock.mockResolvedValueOnce(expiredRow)
|
|
174
177
|
|
|
175
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
178
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
176
179
|
expect(response.status).toBe(409)
|
|
177
180
|
const body = await response.json()
|
|
178
181
|
expect(body.code).toBe('expired')
|
|
@@ -187,7 +190,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
|
|
|
187
190
|
it('409 invalid_status: already confirmed', async () => {
|
|
188
191
|
repoGetByIdMock.mockResolvedValueOnce(makeRow({ status: 'confirmed' }))
|
|
189
192
|
|
|
190
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
193
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
191
194
|
expect(response.status).toBe(409)
|
|
192
195
|
const body = await response.json()
|
|
193
196
|
expect(body.code).toBe('invalid_status')
|
|
@@ -198,7 +201,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
|
|
|
198
201
|
it('409 invalid_status: already executing', async () => {
|
|
199
202
|
repoGetByIdMock.mockResolvedValueOnce(makeRow({ status: 'executing' }))
|
|
200
203
|
|
|
201
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
204
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
202
205
|
expect(response.status).toBe(409)
|
|
203
206
|
const body = await response.json()
|
|
204
207
|
expect(body.code).toBe('invalid_status')
|
|
@@ -207,16 +210,30 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
|
|
|
207
210
|
it('409 invalid_status: already failed', async () => {
|
|
208
211
|
repoGetByIdMock.mockResolvedValueOnce(makeRow({ status: 'failed' }))
|
|
209
212
|
|
|
210
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
213
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
211
214
|
expect(response.status).toBe(409)
|
|
212
215
|
const body = await response.json()
|
|
213
216
|
expect(body.code).toBe('invalid_status')
|
|
214
217
|
})
|
|
215
218
|
|
|
219
|
+
it.each([
|
|
220
|
+
['a non-UUID string', 'not-a-uuid'],
|
|
221
|
+
['a 128-character string', 'a'.repeat(128)],
|
|
222
|
+
])('returns 400 validation_error without querying the database for %s', async (_label, id) => {
|
|
223
|
+
const response = await POST(buildRequest() as any, buildContext(id))
|
|
224
|
+
|
|
225
|
+
expect(response.status).toBe(400)
|
|
226
|
+
const body = await response.json()
|
|
227
|
+
expect(body.code).toBe('validation_error')
|
|
228
|
+
expect(JSON.stringify(body)).not.toContain('invalid input syntax')
|
|
229
|
+
expect(repoGetByIdMock).not.toHaveBeenCalled()
|
|
230
|
+
expect(repoSetStatusMock).not.toHaveBeenCalled()
|
|
231
|
+
})
|
|
232
|
+
|
|
216
233
|
it('404 pending_action_not_found for cross-tenant / unknown id', async () => {
|
|
217
234
|
repoGetByIdMock.mockResolvedValueOnce(null)
|
|
218
235
|
|
|
219
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
236
|
+
const response = await POST(buildRequest() as any, buildContext(MISSING_PENDING_ACTION_ID))
|
|
220
237
|
expect(response.status).toBe(404)
|
|
221
238
|
const body = await response.json()
|
|
222
239
|
expect(body.code).toBe('pending_action_not_found')
|
|
@@ -226,7 +243,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
|
|
|
226
243
|
it('403 forbidden when caller lacks ai_assistant.view', async () => {
|
|
227
244
|
loadAclMock.mockResolvedValueOnce({ features: ['catalog.view'], isSuperAdmin: false })
|
|
228
245
|
|
|
229
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
246
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
230
247
|
expect(response.status).toBe(403)
|
|
231
248
|
const body = await response.json()
|
|
232
249
|
expect(body.code).toBe('forbidden')
|
|
@@ -236,7 +253,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
|
|
|
236
253
|
it('whitespace-only reason becomes empty → default "Cancelled by user" message', async () => {
|
|
237
254
|
repoGetByIdMock.mockResolvedValueOnce(makeRow())
|
|
238
255
|
|
|
239
|
-
const response = await POST(buildRequest({ reason: ' \t\n ' }) as any, buildContext(
|
|
256
|
+
const response = await POST(buildRequest({ reason: ' \t\n ' }) as any, buildContext(PENDING_ACTION_ID))
|
|
240
257
|
expect(response.status).toBe(200)
|
|
241
258
|
const body = await response.json()
|
|
242
259
|
expect(body.pendingAction.executionResult).toEqual({
|
|
@@ -246,7 +263,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
|
|
|
246
263
|
|
|
247
264
|
it('400 validation_error when reason exceeds 500 characters', async () => {
|
|
248
265
|
const longReason = 'x'.repeat(501)
|
|
249
|
-
const response = await POST(buildRequest({ reason: longReason }) as any, buildContext(
|
|
266
|
+
const response = await POST(buildRequest({ reason: longReason }) as any, buildContext(PENDING_ACTION_ID))
|
|
250
267
|
expect(response.status).toBe(400)
|
|
251
268
|
const body = await response.json()
|
|
252
269
|
expect(body.code).toBe('validation_error')
|
|
@@ -256,7 +273,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
|
|
|
256
273
|
it('400 validation_error when body contains unknown fields', async () => {
|
|
257
274
|
const response = await POST(
|
|
258
275
|
buildRequest({ reason: 'ok', evil: 'payload' }) as any,
|
|
259
|
-
buildContext(
|
|
276
|
+
buildContext(PENDING_ACTION_ID),
|
|
260
277
|
)
|
|
261
278
|
expect(response.status).toBe(400)
|
|
262
279
|
const body = await response.json()
|
|
@@ -266,7 +283,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
|
|
|
266
283
|
it('accepts an empty body (no reason)', async () => {
|
|
267
284
|
repoGetByIdMock.mockResolvedValueOnce(makeRow())
|
|
268
285
|
|
|
269
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
286
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
270
287
|
expect(response.status).toBe(200)
|
|
271
288
|
const body = await response.json()
|
|
272
289
|
expect(body.ok).toBe(true)
|
|
@@ -278,7 +295,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
|
|
|
278
295
|
it('500 cancel_internal_error when the repo throws unexpectedly', async () => {
|
|
279
296
|
repoGetByIdMock.mockRejectedValueOnce(new Error('db down'))
|
|
280
297
|
|
|
281
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
298
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
282
299
|
expect(response.status).toBe(500)
|
|
283
300
|
const body = await response.json()
|
|
284
301
|
expect(body.code).toBe('cancel_internal_error')
|
|
@@ -33,11 +33,7 @@ const logger = createLogger('ai_assistant')
|
|
|
33
33
|
const REQUIRED_FEATURE = 'ai_assistant.view'
|
|
34
34
|
|
|
35
35
|
const idParamSchema = z.object({
|
|
36
|
-
id: z
|
|
37
|
-
.string()
|
|
38
|
-
.trim()
|
|
39
|
-
.min(1, 'id must be a non-empty string')
|
|
40
|
-
.max(128, 'id exceeds the maximum length of 128 characters'),
|
|
36
|
+
id: z.string().trim().pipe(z.guid('id must be a UUID')),
|
|
41
37
|
})
|
|
42
38
|
|
|
43
39
|
const bodySchema = z
|
|
@@ -72,7 +68,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
72
68
|
},
|
|
73
69
|
],
|
|
74
70
|
errors: [
|
|
75
|
-
{ status: 400, description: '
|
|
71
|
+
{ status: 400, description: 'The `:id` path parameter is not a UUID, or the cancel request body is invalid (unknown field, reason exceeds 500 chars, wrong type).' },
|
|
76
72
|
{ status: 401, description: 'Unauthenticated caller.' },
|
|
77
73
|
{ status: 403, description: 'Caller lacks `ai_assistant.view`.' },
|
|
78
74
|
{ status: 404, description: 'Pending action not found in the caller scope.' },
|
|
@@ -60,8 +60,11 @@ jest.mock('../../../../../../lib/tool-loader', () => ({
|
|
|
60
60
|
|
|
61
61
|
import { POST } from '../route'
|
|
62
62
|
|
|
63
|
+
const PENDING_ACTION_ID = '3b7e1f2a-6c4d-4a9e-8f1b-2c3d4e5f6a7b'
|
|
64
|
+
const MISSING_PENDING_ACTION_ID = '5e2b8c71-9d4a-4f3e-8b6c-7a1d2e3f4b5c'
|
|
65
|
+
|
|
63
66
|
function buildRequest(): Request {
|
|
64
|
-
return new Request(
|
|
67
|
+
return new Request(`http://localhost/api/ai_assistant/ai/actions/${PENDING_ACTION_ID}/confirm`, {
|
|
65
68
|
method: 'POST',
|
|
66
69
|
headers: { 'content-type': 'application/json' },
|
|
67
70
|
})
|
|
@@ -73,7 +76,7 @@ function buildContext(id: string) {
|
|
|
73
76
|
|
|
74
77
|
function makeRow(overrides: Record<string, unknown> = {}) {
|
|
75
78
|
return {
|
|
76
|
-
id:
|
|
79
|
+
id: PENDING_ACTION_ID,
|
|
77
80
|
tenantId: 'tenant-1',
|
|
78
81
|
organizationId: 'org-1',
|
|
79
82
|
agentId: 'catalog.merchandising_assistant',
|
|
@@ -183,14 +186,14 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
|
|
|
183
186
|
|
|
184
187
|
it('returns 401 when unauthenticated', async () => {
|
|
185
188
|
authMock.mockResolvedValueOnce(null)
|
|
186
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
189
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
187
190
|
expect(response.status).toBe(401)
|
|
188
191
|
})
|
|
189
192
|
|
|
190
193
|
it('happy path: returns 200 with pendingAction.status === confirmed', async () => {
|
|
191
194
|
repoGetByIdMock.mockResolvedValueOnce(makeRow())
|
|
192
195
|
|
|
193
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
196
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
194
197
|
expect(response.status).toBe(200)
|
|
195
198
|
const body = await response.json()
|
|
196
199
|
expect(body.ok).toBe(true)
|
|
@@ -201,7 +204,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
|
|
|
201
204
|
it('409 invalid_status: already cancelled', async () => {
|
|
202
205
|
repoGetByIdMock.mockResolvedValueOnce(makeRow({ status: 'cancelled' }))
|
|
203
206
|
|
|
204
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
207
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
205
208
|
expect(response.status).toBe(409)
|
|
206
209
|
const body = await response.json()
|
|
207
210
|
expect(body.code).toBe('invalid_status')
|
|
@@ -212,7 +215,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
|
|
|
212
215
|
makeRow({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }),
|
|
213
216
|
)
|
|
214
217
|
|
|
215
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
218
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
216
219
|
expect(response.status).toBe(409)
|
|
217
220
|
const body = await response.json()
|
|
218
221
|
expect(body.code).toBe('expired')
|
|
@@ -231,7 +234,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
|
|
|
231
234
|
}),
|
|
232
235
|
)
|
|
233
236
|
|
|
234
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
237
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
235
238
|
expect(response.status).toBe(412)
|
|
236
239
|
const body = await response.json()
|
|
237
240
|
expect(body.code).toBe('stale_version')
|
|
@@ -241,7 +244,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
|
|
|
241
244
|
repoGetByIdMock.mockResolvedValueOnce(makeRow())
|
|
242
245
|
policyOverrideGetMock.mockResolvedValueOnce({ mutationPolicy: 'read-only' })
|
|
243
246
|
|
|
244
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
247
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
245
248
|
expect(response.status).toBe(403)
|
|
246
249
|
const body = await response.json()
|
|
247
250
|
expect(body.code).toBe('read_only_agent')
|
|
@@ -251,7 +254,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
|
|
|
251
254
|
repoGetByIdMock.mockResolvedValueOnce(makeRow())
|
|
252
255
|
getAgentMock.mockReturnValueOnce(makeAgent({ allowedTools: [] }))
|
|
253
256
|
|
|
254
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
257
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
255
258
|
expect(response.status).toBe(403)
|
|
256
259
|
const body = await response.json()
|
|
257
260
|
expect(body.code).toBe('tool_not_whitelisted')
|
|
@@ -261,7 +264,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
|
|
|
261
264
|
repoGetByIdMock.mockResolvedValueOnce(makeRow())
|
|
262
265
|
getAgentMock.mockReturnValueOnce(makeAgent({ requiredFeatures: ['catalog.restricted.manage'] }))
|
|
263
266
|
|
|
264
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
267
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
265
268
|
expect(response.status).toBe(403)
|
|
266
269
|
const body = await response.json()
|
|
267
270
|
expect(body.code).toBe('agent_features_denied')
|
|
@@ -273,7 +276,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
|
|
|
273
276
|
{ id: 'a-1', tenantId: 'tenant-OTHER', organizationId: 'org-OTHER' },
|
|
274
277
|
])
|
|
275
278
|
|
|
276
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
279
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
277
280
|
expect(response.status).toBe(403)
|
|
278
281
|
const body = await response.json()
|
|
279
282
|
expect(body.code).toBe('attachment_cross_tenant')
|
|
@@ -283,16 +286,29 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
|
|
|
283
286
|
repoGetByIdMock.mockResolvedValueOnce(makeRow())
|
|
284
287
|
getAgentMock.mockReturnValueOnce(undefined)
|
|
285
288
|
|
|
286
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
289
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
287
290
|
expect(response.status).toBe(404)
|
|
288
291
|
const body = await response.json()
|
|
289
292
|
expect(body.code).toBe('agent_unknown')
|
|
290
293
|
})
|
|
291
294
|
|
|
295
|
+
it.each([
|
|
296
|
+
['a non-UUID string', 'not-a-uuid'],
|
|
297
|
+
['a 128-character string', 'a'.repeat(128)],
|
|
298
|
+
])('returns 400 validation_error without querying the database for %s', async (_label, id) => {
|
|
299
|
+
const response = await POST(buildRequest() as any, buildContext(id))
|
|
300
|
+
|
|
301
|
+
expect(response.status).toBe(400)
|
|
302
|
+
const body = await response.json()
|
|
303
|
+
expect(body.code).toBe('validation_error')
|
|
304
|
+
expect(JSON.stringify(body)).not.toContain('invalid input syntax')
|
|
305
|
+
expect(repoGetByIdMock).not.toHaveBeenCalled()
|
|
306
|
+
})
|
|
307
|
+
|
|
292
308
|
it('404 pending_action_not_found when the row does not exist for the caller', async () => {
|
|
293
309
|
repoGetByIdMock.mockResolvedValueOnce(null)
|
|
294
310
|
|
|
295
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
311
|
+
const response = await POST(buildRequest() as any, buildContext(MISSING_PENDING_ACTION_ID))
|
|
296
312
|
expect(response.status).toBe(404)
|
|
297
313
|
const body = await response.json()
|
|
298
314
|
expect(body.code).toBe('pending_action_not_found')
|
|
@@ -301,7 +317,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
|
|
|
301
317
|
it('403 forbidden when caller lacks ai_assistant.view', async () => {
|
|
302
318
|
loadAclMock.mockResolvedValueOnce({ features: ['catalog.view'], isSuperAdmin: false })
|
|
303
319
|
|
|
304
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
320
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
305
321
|
expect(response.status).toBe(403)
|
|
306
322
|
const body = await response.json()
|
|
307
323
|
expect(body.code).toBe('forbidden')
|
|
@@ -319,7 +335,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
|
|
|
319
335
|
const handlerSpy = jest.fn()
|
|
320
336
|
getToolMock.mockReturnValueOnce(makeTool({ handler: handlerSpy }))
|
|
321
337
|
|
|
322
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
338
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
323
339
|
expect(response.status).toBe(200)
|
|
324
340
|
const body = await response.json()
|
|
325
341
|
expect(body.ok).toBe(true)
|
|
@@ -331,7 +347,7 @@ describe('POST /api/ai/actions/:id/confirm route (Step 5.8)', () => {
|
|
|
331
347
|
it('500 confirm_internal_error when the repo throws unexpectedly', async () => {
|
|
332
348
|
repoGetByIdMock.mockRejectedValueOnce(new Error('db down'))
|
|
333
349
|
|
|
334
|
-
const response = await POST(buildRequest() as any, buildContext(
|
|
350
|
+
const response = await POST(buildRequest() as any, buildContext(PENDING_ACTION_ID))
|
|
335
351
|
expect(response.status).toBe(500)
|
|
336
352
|
const body = await response.json()
|
|
337
353
|
expect(body.code).toBe('confirm_internal_error')
|
|
@@ -44,11 +44,7 @@ const logger = createLogger('ai_assistant')
|
|
|
44
44
|
const REQUIRED_FEATURE = 'ai_assistant.view'
|
|
45
45
|
|
|
46
46
|
const idParamSchema = z.object({
|
|
47
|
-
id: z
|
|
48
|
-
.string()
|
|
49
|
-
.trim()
|
|
50
|
-
.min(1, 'id must be a non-empty string')
|
|
51
|
-
.max(128, 'id exceeds the maximum length of 128 characters'),
|
|
47
|
+
id: z.string().trim().pipe(z.guid('id must be a UUID')),
|
|
52
48
|
})
|
|
53
49
|
|
|
54
50
|
export const openApi: OpenApiRouteDoc = {
|
|
@@ -73,6 +69,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
73
69
|
},
|
|
74
70
|
],
|
|
75
71
|
errors: [
|
|
72
|
+
{ status: 400, description: 'The `:id` path parameter is not a UUID.' },
|
|
76
73
|
{ status: 401, description: 'Unauthenticated caller.' },
|
|
77
74
|
{ status: 403, description: 'Caller lacks `ai_assistant.view`, a required agent feature, tool whitelist, or accesses attachments outside their tenant.' },
|
|
78
75
|
{ status: 404, description: 'Pending action or agent not found in the caller scope.' },
|
|
@@ -26,11 +26,7 @@ const logger = createLogger('ai_assistant')
|
|
|
26
26
|
const REQUIRED_FEATURE = 'ai_assistant.view'
|
|
27
27
|
|
|
28
28
|
const idParamSchema = z.object({
|
|
29
|
-
id: z
|
|
30
|
-
.string()
|
|
31
|
-
.trim()
|
|
32
|
-
.min(1, 'id must be a non-empty string')
|
|
33
|
-
.max(128, 'id exceeds the maximum length of 128 characters'),
|
|
29
|
+
id: z.string().trim().pipe(z.guid('id must be a UUID')),
|
|
34
30
|
})
|
|
35
31
|
|
|
36
32
|
export const openApi: OpenApiRouteDoc = {
|
|
@@ -56,6 +52,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
56
52
|
},
|
|
57
53
|
],
|
|
58
54
|
errors: [
|
|
55
|
+
{ status: 400, description: 'The `:id` path parameter is not a UUID.' },
|
|
59
56
|
{ status: 401, description: 'Unauthenticated caller.' },
|
|
60
57
|
{ status: 403, description: 'Caller lacks the `ai_assistant.view` feature.' },
|
|
61
58
|
{ status: 404, description: 'No pending action with the given id is accessible to the caller.' },
|