@open-mercato/ai-assistant 0.10.1-develop.7618.1.ba9531c18e → 0.10.1-develop.7622.1.0584ba2a04
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/data/repositories/AiPendingActionRepository.js +8 -4
- package/dist/modules/ai_assistant/data/repositories/AiPendingActionRepository.js.map +2 -2
- package/dist/modules/ai_assistant/lib/agent-tools.js +17 -3
- package/dist/modules/ai_assistant/lib/agent-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/prepare-mutation.js +2 -2
- package/dist/modules/ai_assistant/lib/prepare-mutation.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/ai_assistant/data/repositories/AiPendingActionRepository.ts +49 -14
- package/src/modules/ai_assistant/data/repositories/__tests__/AiPendingActionRepository.test.ts +146 -15
- package/src/modules/ai_assistant/lib/__tests__/prepare-mutation.test.ts +98 -0
- package/src/modules/ai_assistant/lib/agent-tools.ts +29 -1
- package/src/modules/ai_assistant/lib/prepare-mutation.ts +8 -2
|
@@ -56,10 +56,14 @@ class AiPendingActionRepository {
|
|
|
56
56
|
}
|
|
57
57
|
);
|
|
58
58
|
if (existing && existing.status === "pending") {
|
|
59
|
-
return existing;
|
|
59
|
+
return { row: existing, reused: true };
|
|
60
|
+
}
|
|
61
|
+
if (existing && (existing.status === "confirmed" || existing.status === "executing") && now.getTime() < existing.expiresAt.getTime()) {
|
|
62
|
+
return { row: existing, reused: true };
|
|
60
63
|
}
|
|
61
|
-
if (existing && (existing.status === "failed" || existing.status === "cancelled" || existing.status === "expired")) {
|
|
62
|
-
|
|
64
|
+
if (existing && (existing.status === "failed" || existing.status === "cancelled" || existing.status === "expired" || existing.status === "confirmed" || existing.status === "executing")) {
|
|
65
|
+
existing.idempotencyKey = `${existing.idempotencyKey}:superseded:${existing.id}`;
|
|
66
|
+
await tx.persist(existing).flush();
|
|
63
67
|
}
|
|
64
68
|
const row = tx.create(AiPendingAction, {
|
|
65
69
|
tenantId: ctx.tenantId,
|
|
@@ -87,7 +91,7 @@ class AiPendingActionRepository {
|
|
|
87
91
|
resolvedByUserId: null
|
|
88
92
|
});
|
|
89
93
|
await tx.persist(row).flush();
|
|
90
|
-
return row;
|
|
94
|
+
return { row, reused: false };
|
|
91
95
|
});
|
|
92
96
|
}
|
|
93
97
|
async getById(id, ctx) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/ai_assistant/data/repositories/AiPendingActionRepository.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n findOneWithDecryption,\n findWithDecryption,\n} from '@open-mercato/shared/lib/encryption/find'\nimport { AiPendingAction } from '../entities'\nimport {\n AI_PENDING_ACTION_ALLOWED_TRANSITIONS,\n AiPendingActionStateError,\n resolveAiPendingActionTtlSeconds,\n type AiPendingActionExecutionResult,\n type AiPendingActionFailedRecord,\n type AiPendingActionFieldDiff,\n type AiPendingActionQueueMode,\n type AiPendingActionRecordDiff,\n type AiPendingActionStatus,\n} from '../../lib/pending-action-types'\n\nexport interface AiPendingActionContext {\n tenantId: string\n organizationId?: string | null\n userId?: string | null\n}\n\nexport interface AiPendingActionCreateInput {\n agentId: string\n toolName: string\n idempotencyKey: string\n createdByUserId: string\n normalizedInput: Record<string, unknown>\n conversationId?: string | null\n targetEntityType?: string | null\n targetRecordId?: string | null\n fieldDiff?: AiPendingActionFieldDiff[]\n records?: AiPendingActionRecordDiff[] | null\n sideEffectsSummary?: string | null\n recordVersion?: string | null\n attachmentIds?: string[]\n queueMode?: AiPendingActionQueueMode\n /** Optional explicit TTL in seconds; overrides the env/default TTL. */\n ttlSeconds?: number\n /** Optional explicit `now` for deterministic tests. */\n now?: Date\n}\n\nexport interface AiPendingActionSetStatusExtra {\n resolvedByUserId?: string | null\n executionResult?: AiPendingActionExecutionResult | null\n failedRecords?: AiPendingActionFailedRecord[] | null\n /** Optional explicit `now` for deterministic tests. */\n now?: Date\n}\n\n/**\n * Persistent store for the Phase 3 WS-C mutation approval gate (Step 5.5).\n *\n * Responsibilities:\n * - Create new pending rows with a TTL-derived `expiresAt`, honoring\n * idempotency within the window (same `idempotencyKey` returns the same\n * row as long as it is still `pending`; any terminal state mints a new row).\n * - Tenant-scoped lookups for the confirm/cancel/reconnect routes and the\n * in-app UI's \"open actions\" list.\n * - State-machine enforcement: `setStatus` rejects illegal transitions via\n * {@link AiPendingActionStateError}. The runtime callers translate this\n * to a 409 Conflict response.\n * - `listExpired` for the cleanup worker (Step 5.12).\n *\n * Every read goes through `findOneWithDecryption` / `findWithDecryption`\n * because `normalized_input`, `field_diff` and `records` are declared in the\n * module's `encryption.ts` and are encrypted at rest when tenant data\n * encryption is enabled.\n */\nexport class AiPendingActionRepository {\n constructor(private readonly em: EntityManager) {}\n\n async create(\n input: AiPendingActionCreateInput,\n ctx: AiPendingActionContext,\n ): Promise<AiPendingAction> {\n if (!ctx?.tenantId) {\n throw new Error('AiPendingActionRepository.create requires tenantId')\n }\n if (!input?.agentId) {\n throw new Error('AiPendingActionRepository.create requires agentId')\n }\n if (!input?.toolName) {\n throw new Error('AiPendingActionRepository.create requires toolName')\n }\n if (!input?.idempotencyKey) {\n throw new Error(\n 'AiPendingActionRepository.create requires idempotencyKey',\n )\n }\n if (!input?.createdByUserId) {\n throw new Error(\n 'AiPendingActionRepository.create requires createdByUserId',\n )\n }\n\n const now = input.now ?? new Date()\n const ttlSeconds = Math.max(\n 1,\n Math.floor(\n typeof input.ttlSeconds === 'number' && Number.isFinite(input.ttlSeconds)\n ? input.ttlSeconds\n : resolveAiPendingActionTtlSeconds(),\n ),\n )\n const expiresAt = new Date(now.getTime() + ttlSeconds * 1000)\n\n return this.em.transactional(async (tx) => {\n const existing = await findOneWithDecryption<AiPendingAction>(\n tx as unknown as EntityManager,\n AiPendingAction,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n idempotencyKey: input.idempotencyKey,\n } as any,\n { orderBy: { createdAt: 'desc' } as any },\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n if (existing && existing.status === 'pending') {\n return existing\n }\n // Terminal stale row would collide on the unique\n // `(tenantId, organizationId, idempotencyKey)` constraint when we\n // try to insert a fresh one with the same hash \u2014 and that exact\n // collision happens whenever the operator clicks \"Fix with AI\"\n // and the model retries the SAME tool with the SAME args. Remove\n // the stale row first so a retry can always proceed; success rows\n // stay (they represent a real, applied change), and failed /\n // cancelled / expired rows are cleared because they're blocking\n // exactly the recovery flow they were created to enable.\n if (\n existing &&\n (existing.status === 'failed' ||\n existing.status === 'cancelled' ||\n existing.status === 'expired')\n ) {\n await tx.remove(existing).flush()\n }\n const row = tx.create(AiPendingAction, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n agentId: input.agentId,\n toolName: input.toolName,\n conversationId: input.conversationId ?? null,\n targetEntityType: input.targetEntityType ?? null,\n targetRecordId: input.targetRecordId ?? null,\n normalizedInput: input.normalizedInput ?? {},\n fieldDiff: Array.isArray(input.fieldDiff) ? input.fieldDiff : [],\n records: normalizeRecords(input.records),\n failedRecords: null,\n sideEffectsSummary: input.sideEffectsSummary ?? null,\n recordVersion: input.recordVersion ?? null,\n attachmentIds: Array.isArray(input.attachmentIds)\n ? input.attachmentIds\n : [],\n idempotencyKey: input.idempotencyKey,\n createdByUserId: input.createdByUserId,\n status: 'pending' as AiPendingActionStatus,\n queueMode: (input.queueMode ?? 'inline') as AiPendingActionQueueMode,\n executionResult: null,\n createdAt: now,\n expiresAt,\n resolvedAt: null,\n resolvedByUserId: null,\n } as unknown as AiPendingAction)\n await tx.persist(row).flush()\n return row\n })\n }\n\n async getById(\n id: string,\n ctx: AiPendingActionContext,\n ): Promise<AiPendingAction | null> {\n if (!id || !ctx?.tenantId) return null\n const row = await findOneWithDecryption<AiPendingAction>(\n this.em,\n AiPendingAction,\n {\n id,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n } as any,\n {},\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n return row ?? null\n }\n\n async listPendingForAgent(\n agentId: string,\n ctx: AiPendingActionContext,\n limit: number = 50,\n ): Promise<AiPendingAction[]> {\n if (!agentId || !ctx?.tenantId) return []\n const capped = Math.max(1, Math.min(Math.floor(limit), 200))\n const rows = await findWithDecryption<AiPendingAction>(\n this.em,\n AiPendingAction,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n agentId,\n status: 'pending',\n } as any,\n {\n orderBy: { createdAt: 'desc' } as any,\n limit: capped,\n },\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n return rows\n }\n\n async setStatus(\n id: string,\n nextStatus: AiPendingActionStatus,\n ctx: AiPendingActionContext,\n extra?: AiPendingActionSetStatusExtra,\n ): Promise<AiPendingAction> {\n if (!ctx?.tenantId) {\n throw new Error('AiPendingActionRepository.setStatus requires tenantId')\n }\n if (!id) {\n throw new Error('AiPendingActionRepository.setStatus requires id')\n }\n return this.em.transactional(async (tx) => {\n const existing = await findOneWithDecryption<AiPendingAction>(\n tx as unknown as EntityManager,\n AiPendingAction,\n {\n id,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n } as any,\n {},\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n if (!existing) {\n throw new Error(`AiPendingAction not found: ${id}`)\n }\n if (existing.status === nextStatus) {\n return existing\n }\n const allowed = AI_PENDING_ACTION_ALLOWED_TRANSITIONS[existing.status] ?? []\n if (!allowed.includes(nextStatus)) {\n throw new AiPendingActionStateError(existing.status, nextStatus)\n }\n const now = extra?.now ?? new Date()\n existing.status = nextStatus\n if (\n nextStatus === 'confirmed' ||\n nextStatus === 'cancelled' ||\n nextStatus === 'expired' ||\n nextStatus === 'failed'\n ) {\n existing.resolvedAt = existing.resolvedAt ?? now\n if (extra && Object.prototype.hasOwnProperty.call(extra, 'resolvedByUserId')) {\n existing.resolvedByUserId = extra.resolvedByUserId ?? null\n } else if (nextStatus === 'expired') {\n existing.resolvedByUserId = null\n }\n }\n if (extra && Object.prototype.hasOwnProperty.call(extra, 'executionResult')) {\n existing.executionResult = extra.executionResult ?? null\n }\n if (extra && Object.prototype.hasOwnProperty.call(extra, 'failedRecords')) {\n existing.failedRecords = normalizeFailedRecords(extra.failedRecords)\n }\n await tx.persist(existing).flush()\n return existing\n })\n }\n\n async listExpired(\n ctx: AiPendingActionContext,\n now: Date,\n limit: number = 100,\n ): Promise<AiPendingAction[]> {\n if (!ctx?.tenantId) return []\n const capped = Math.max(1, Math.min(Math.floor(limit), 500))\n const rows = await findWithDecryption<AiPendingAction>(\n this.em,\n AiPendingAction,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n status: 'pending',\n expiresAt: { $lt: now } as any,\n } as any,\n {\n orderBy: { expiresAt: 'asc' } as any,\n limit: capped,\n },\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n return rows\n }\n}\n\nfunction normalizeRecords(\n records: AiPendingActionRecordDiff[] | null | undefined,\n): AiPendingActionRecordDiff[] | null {\n if (!Array.isArray(records) || records.length === 0) return null\n return records\n}\n\nfunction normalizeFailedRecords(\n failed: AiPendingActionFailedRecord[] | null | undefined,\n): AiPendingActionFailedRecord[] | null {\n if (!Array.isArray(failed) || failed.length === 0) return null\n return failed\n}\n\nexport default AiPendingActionRepository\n"],
|
|
5
|
-
"mappings": "AACA;AAAA,EACE;AAAA,EACA;AAAA,OACK;AACP,SAAS,uBAAuB;AAChC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAOK;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n findOneWithDecryption,\n findWithDecryption,\n} from '@open-mercato/shared/lib/encryption/find'\nimport { AiPendingAction } from '../entities'\nimport {\n AI_PENDING_ACTION_ALLOWED_TRANSITIONS,\n AiPendingActionStateError,\n resolveAiPendingActionTtlSeconds,\n type AiPendingActionExecutionResult,\n type AiPendingActionFailedRecord,\n type AiPendingActionFieldDiff,\n type AiPendingActionQueueMode,\n type AiPendingActionRecordDiff,\n type AiPendingActionStatus,\n} from '../../lib/pending-action-types'\n\nexport interface AiPendingActionContext {\n tenantId: string\n organizationId?: string | null\n userId?: string | null\n}\n\nexport interface AiPendingActionCreateInput {\n agentId: string\n toolName: string\n idempotencyKey: string\n createdByUserId: string\n normalizedInput: Record<string, unknown>\n conversationId?: string | null\n targetEntityType?: string | null\n targetRecordId?: string | null\n fieldDiff?: AiPendingActionFieldDiff[]\n records?: AiPendingActionRecordDiff[] | null\n sideEffectsSummary?: string | null\n recordVersion?: string | null\n attachmentIds?: string[]\n queueMode?: AiPendingActionQueueMode\n /** Optional explicit TTL in seconds; overrides the env/default TTL. */\n ttlSeconds?: number\n /** Optional explicit `now` for deterministic tests. */\n now?: Date\n}\n\nexport interface AiPendingActionCreateResult {\n row: AiPendingAction\n /**\n * True when `row` is an existing row returned as-is (a still-`pending`\n * row, or a `confirmed`/`executing` row whose mutation already ran) \u2014\n * i.e. no new row was inserted.\n */\n reused: boolean\n}\n\nexport interface AiPendingActionSetStatusExtra {\n resolvedByUserId?: string | null\n executionResult?: AiPendingActionExecutionResult | null\n failedRecords?: AiPendingActionFailedRecord[] | null\n /** Optional explicit `now` for deterministic tests. */\n now?: Date\n}\n\n/**\n * Persistent store for the Phase 3 WS-C mutation approval gate (Step 5.5).\n *\n * Responsibilities:\n * - Create new pending rows with a TTL-derived `expiresAt`, honoring\n * idempotency within the window (same `idempotencyKey` returns the same\n * row as long as it is still `pending`; any terminal state mints a new row).\n * - Tenant-scoped lookups for the confirm/cancel/reconnect routes and the\n * in-app UI's \"open actions\" list.\n * - State-machine enforcement: `setStatus` rejects illegal transitions via\n * {@link AiPendingActionStateError}. The runtime callers translate this\n * to a 409 Conflict response.\n * - `listExpired` for the cleanup worker (Step 5.12).\n *\n * Every read goes through `findOneWithDecryption` / `findWithDecryption`\n * because `normalized_input`, `field_diff` and `records` are declared in the\n * module's `encryption.ts` and are encrypted at rest when tenant data\n * encryption is enabled.\n */\nexport class AiPendingActionRepository {\n constructor(private readonly em: EntityManager) {}\n\n async create(\n input: AiPendingActionCreateInput,\n ctx: AiPendingActionContext,\n ): Promise<AiPendingActionCreateResult> {\n if (!ctx?.tenantId) {\n throw new Error('AiPendingActionRepository.create requires tenantId')\n }\n if (!input?.agentId) {\n throw new Error('AiPendingActionRepository.create requires agentId')\n }\n if (!input?.toolName) {\n throw new Error('AiPendingActionRepository.create requires toolName')\n }\n if (!input?.idempotencyKey) {\n throw new Error(\n 'AiPendingActionRepository.create requires idempotencyKey',\n )\n }\n if (!input?.createdByUserId) {\n throw new Error(\n 'AiPendingActionRepository.create requires createdByUserId',\n )\n }\n\n const now = input.now ?? new Date()\n const ttlSeconds = Math.max(\n 1,\n Math.floor(\n typeof input.ttlSeconds === 'number' && Number.isFinite(input.ttlSeconds)\n ? input.ttlSeconds\n : resolveAiPendingActionTtlSeconds(),\n ),\n )\n const expiresAt = new Date(now.getTime() + ttlSeconds * 1000)\n\n return this.em.transactional(async (tx) => {\n const existing = await findOneWithDecryption<AiPendingAction>(\n tx as unknown as EntityManager,\n AiPendingAction,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n idempotencyKey: input.idempotencyKey,\n } as any,\n { orderBy: { createdAt: 'desc' } as any },\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n if (existing && existing.status === 'pending') {\n return { row: existing, reused: true }\n }\n // A `confirmed`/`executing` row represents a mutation that already\n // ran (or is running). Inserting a fresh row with the same key would\n // collide with the unique `(tenantId, organizationId,\n // idempotencyKey)` index \u2014 this happens whenever the model repeats\n // the same tool call with the same arguments after it already\n // succeeded. Return the existing row instead of inserting; the\n // caller (`prepareMutation`) uses `reused` to tell the model the\n // operation already ran rather than re-queuing it.\n //\n // This only holds within the row's own dedupe/TTL window\n // (`now < expiresAt`, per the spec's \"within the TTL\" dedupe\n // promise). Outside it, treat the row as stale: a legitimate repeat\n // of the same arguments later on would otherwise be refused forever,\n // and a process that died mid-execution would leave every retry\n // stuck reading a dead `executing` row.\n if (\n existing &&\n (existing.status === 'confirmed' || existing.status === 'executing') &&\n now.getTime() < existing.expiresAt.getTime()\n ) {\n return { row: existing, reused: true }\n }\n // A terminal (`failed`/`cancelled`/`expired`) row, or a stale\n // `confirmed`/`executing` row past its dedupe window, would also\n // collide on the same unique index when we insert a fresh one with\n // the same key \u2014 which happens whenever the operator clicks \"Fix\n // with AI\" and the model retries the SAME tool with the SAME args,\n // or simply repeats the same arguments later. Re-key the stale row\n // (instead of deleting it) so a retry can always proceed: the stale\n // row's own card keeps resolving to its terminal/prior state via a\n // GET by id, rather than 404ing.\n if (\n existing &&\n (existing.status === 'failed' ||\n existing.status === 'cancelled' ||\n existing.status === 'expired' ||\n existing.status === 'confirmed' ||\n existing.status === 'executing')\n ) {\n existing.idempotencyKey = `${existing.idempotencyKey}:superseded:${existing.id}`\n await tx.persist(existing).flush()\n }\n const row = tx.create(AiPendingAction, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n agentId: input.agentId,\n toolName: input.toolName,\n conversationId: input.conversationId ?? null,\n targetEntityType: input.targetEntityType ?? null,\n targetRecordId: input.targetRecordId ?? null,\n normalizedInput: input.normalizedInput ?? {},\n fieldDiff: Array.isArray(input.fieldDiff) ? input.fieldDiff : [],\n records: normalizeRecords(input.records),\n failedRecords: null,\n sideEffectsSummary: input.sideEffectsSummary ?? null,\n recordVersion: input.recordVersion ?? null,\n attachmentIds: Array.isArray(input.attachmentIds)\n ? input.attachmentIds\n : [],\n idempotencyKey: input.idempotencyKey,\n createdByUserId: input.createdByUserId,\n status: 'pending' as AiPendingActionStatus,\n queueMode: (input.queueMode ?? 'inline') as AiPendingActionQueueMode,\n executionResult: null,\n createdAt: now,\n expiresAt,\n resolvedAt: null,\n resolvedByUserId: null,\n } as unknown as AiPendingAction)\n await tx.persist(row).flush()\n return { row, reused: false }\n })\n }\n\n async getById(\n id: string,\n ctx: AiPendingActionContext,\n ): Promise<AiPendingAction | null> {\n if (!id || !ctx?.tenantId) return null\n const row = await findOneWithDecryption<AiPendingAction>(\n this.em,\n AiPendingAction,\n {\n id,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n } as any,\n {},\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n return row ?? null\n }\n\n async listPendingForAgent(\n agentId: string,\n ctx: AiPendingActionContext,\n limit: number = 50,\n ): Promise<AiPendingAction[]> {\n if (!agentId || !ctx?.tenantId) return []\n const capped = Math.max(1, Math.min(Math.floor(limit), 200))\n const rows = await findWithDecryption<AiPendingAction>(\n this.em,\n AiPendingAction,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n agentId,\n status: 'pending',\n } as any,\n {\n orderBy: { createdAt: 'desc' } as any,\n limit: capped,\n },\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n return rows\n }\n\n async setStatus(\n id: string,\n nextStatus: AiPendingActionStatus,\n ctx: AiPendingActionContext,\n extra?: AiPendingActionSetStatusExtra,\n ): Promise<AiPendingAction> {\n if (!ctx?.tenantId) {\n throw new Error('AiPendingActionRepository.setStatus requires tenantId')\n }\n if (!id) {\n throw new Error('AiPendingActionRepository.setStatus requires id')\n }\n return this.em.transactional(async (tx) => {\n const existing = await findOneWithDecryption<AiPendingAction>(\n tx as unknown as EntityManager,\n AiPendingAction,\n {\n id,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n } as any,\n {},\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n if (!existing) {\n throw new Error(`AiPendingAction not found: ${id}`)\n }\n if (existing.status === nextStatus) {\n return existing\n }\n const allowed = AI_PENDING_ACTION_ALLOWED_TRANSITIONS[existing.status] ?? []\n if (!allowed.includes(nextStatus)) {\n throw new AiPendingActionStateError(existing.status, nextStatus)\n }\n const now = extra?.now ?? new Date()\n existing.status = nextStatus\n if (\n nextStatus === 'confirmed' ||\n nextStatus === 'cancelled' ||\n nextStatus === 'expired' ||\n nextStatus === 'failed'\n ) {\n existing.resolvedAt = existing.resolvedAt ?? now\n if (extra && Object.prototype.hasOwnProperty.call(extra, 'resolvedByUserId')) {\n existing.resolvedByUserId = extra.resolvedByUserId ?? null\n } else if (nextStatus === 'expired') {\n existing.resolvedByUserId = null\n }\n }\n if (extra && Object.prototype.hasOwnProperty.call(extra, 'executionResult')) {\n existing.executionResult = extra.executionResult ?? null\n }\n if (extra && Object.prototype.hasOwnProperty.call(extra, 'failedRecords')) {\n existing.failedRecords = normalizeFailedRecords(extra.failedRecords)\n }\n await tx.persist(existing).flush()\n return existing\n })\n }\n\n async listExpired(\n ctx: AiPendingActionContext,\n now: Date,\n limit: number = 100,\n ): Promise<AiPendingAction[]> {\n if (!ctx?.tenantId) return []\n const capped = Math.max(1, Math.min(Math.floor(limit), 500))\n const rows = await findWithDecryption<AiPendingAction>(\n this.em,\n AiPendingAction,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n status: 'pending',\n expiresAt: { $lt: now } as any,\n } as any,\n {\n orderBy: { expiresAt: 'asc' } as any,\n limit: capped,\n },\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n return rows\n }\n}\n\nfunction normalizeRecords(\n records: AiPendingActionRecordDiff[] | null | undefined,\n): AiPendingActionRecordDiff[] | null {\n if (!Array.isArray(records) || records.length === 0) return null\n return records\n}\n\nfunction normalizeFailedRecords(\n failed: AiPendingActionFailedRecord[] | null | undefined,\n): AiPendingActionFailedRecord[] | null {\n if (!Array.isArray(failed) || failed.length === 0) return null\n return failed\n}\n\nexport default AiPendingActionRepository\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -64,7 +64,18 @@ function buildToolHandlerContext(ctx, container, tool) {
|
|
|
64
64
|
...tool ? { tool } : {}
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
|
-
function formatPendingActionToolResult(agent, tool, pendingActionId, expiresAt) {
|
|
67
|
+
function formatPendingActionToolResult(agent, tool, pendingActionId, expiresAt, reused, status, executionResult) {
|
|
68
|
+
if (reused && (status === "confirmed" || status === "executing")) {
|
|
69
|
+
const resultStatus = status === "executing" ? "executing" : "already-executed";
|
|
70
|
+
return formatToolResult({
|
|
71
|
+
status: resultStatus,
|
|
72
|
+
agentId: agent.id,
|
|
73
|
+
toolName: tool.name,
|
|
74
|
+
pendingActionId,
|
|
75
|
+
executionResult: executionResult ?? null,
|
|
76
|
+
message: resultStatus === "executing" ? `Mutation "${tool.name}" is already executing from a prior confirmed call. Do NOT submit it again; wait for the result.` : `Mutation "${tool.name}" already ran for this input (idempotency key reused). Do NOT submit it again \u2014 see executionResult for the prior outcome.`
|
|
77
|
+
});
|
|
78
|
+
}
|
|
68
79
|
return formatToolResult({
|
|
69
80
|
status: "pending-confirmation",
|
|
70
81
|
agentId: agent.id,
|
|
@@ -102,7 +113,7 @@ function adaptToolToAiSdk(tool, ctx, mutation, container, effectiveMutationPolic
|
|
|
102
113
|
if (mutation && requiresApprovalNow) {
|
|
103
114
|
try {
|
|
104
115
|
const toolCallArgs = args && typeof args === "object" && !Array.isArray(args) ? { ...args } : {};
|
|
105
|
-
const { uiPart, pendingAction } = await prepareMutation(
|
|
116
|
+
const { uiPart, pendingAction, reused } = await prepareMutation(
|
|
106
117
|
{
|
|
107
118
|
agent: mutation.agent,
|
|
108
119
|
tool: mutation.tool,
|
|
@@ -124,7 +135,10 @@ function adaptToolToAiSdk(tool, ctx, mutation, container, effectiveMutationPolic
|
|
|
124
135
|
mutation.agent,
|
|
125
136
|
mutation.tool,
|
|
126
137
|
pendingAction.id,
|
|
127
|
-
pendingAction.expiresAt
|
|
138
|
+
pendingAction.expiresAt,
|
|
139
|
+
reused,
|
|
140
|
+
pendingAction.status,
|
|
141
|
+
pendingAction.executionResult ?? null
|
|
128
142
|
);
|
|
129
143
|
} catch (error) {
|
|
130
144
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/ai_assistant/lib/agent-tools.ts"],
|
|
4
|
-
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { dynamicTool, type Tool } from 'ai'\nimport type { AwilixContainer } from 'awilix'\nimport type { ZodType } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { AiAgentDefinition, AiAgentMutationPolicy } from './ai-agent-definition'\nimport type { AiChatRequestContext, AiUiPart } from './attachment-bridge-types'\nimport type { AiToolDefinition, McpToolContext } from './types'\nimport { loadAgentRegistry } from './agent-registry'\nimport {\n checkAgentPolicy,\n resolveEffectiveMutationPolicy,\n type AgentPolicyDenyCode,\n} from './agent-policy'\nimport { toolRegistry } from './tool-registry'\nimport { toSafeZodSchema } from './schema-utils'\nimport { prepareMutation } from './prepare-mutation'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * Error thrown by `resolveAiAgentTools` (and downstream `runAiAgentText`) when\n * the agent-level policy check denies a request. Carries the structured deny\n * code so HTTP dispatchers can map it to a stable status and JSON body.\n */\nexport class AgentPolicyError extends Error {\n readonly code: AgentPolicyDenyCode\n\n constructor(code: AgentPolicyDenyCode, message: string) {\n super(message)\n this.name = 'AgentPolicyError'\n this.code = code\n }\n}\n\nexport interface ResolveAiAgentToolsInput {\n agentId: string\n authContext: AiChatRequestContext\n pageContext?: Record<string, unknown>\n attachmentIds?: string[]\n /**\n * Execution mode the caller intends to run the agent in. Defaults to\n * `'chat'` to preserve the existing chat dispatcher contract. Object-mode\n * callers (see `runAiAgentObject`) MUST pass `'object'` so the policy gate\n * can reject chat-only agents early with `execution_mode_not_supported`.\n */\n requestedExecutionMode?: 'chat' | 'object'\n /**\n * Optional tenant-scoped mutation-policy DOWNGRADE. When supplied, the\n * effective policy evaluated by `checkAgentPolicy` is the most restrictive\n * of `{ agent.mutationPolicy, mutationPolicyOverride }`. Escalation is\n * rejected at the route layer; this helper trusts callers to pass only\n * values produced by the override repository.\n */\n mutationPolicyOverride?: AiAgentMutationPolicy | null\n /**\n * DI container used by the `prepareMutation` tool-call wrapper (Step 5.6).\n * When present AND the agent's effective mutation policy is non-read-only,\n * `isMutation: true` tools are intercepted: the runtime creates an\n * `AiPendingAction` row and enqueues a `mutation-preview-card` UI part in\n * the returned {@link ResolvedAgentTools.uiPartQueue} instead of running the\n * tool's handler. When absent, mutation tools degrade-gracefully to the\n * pre-5.6 pass-through adapter \u2014 existing read-only agents are unaffected.\n */\n container?: AwilixContainer\n /**\n * Optional chat-turn correlation id used when hashing the\n * `AiPendingAction.idempotencyKey` so retries of the same mutation collapse\n * to a single row. The chat dispatcher supplies the OpenCode / AI SDK turn\n * id here; when omitted the hash falls back to `null` which still preserves\n * per-tenant/org uniqueness within the TTL window.\n */\n conversationId?: string | null\n}\n\n/**\n * Queue of UI parts the mutation-preview wrapper accumulates during a turn.\n * The chat/object dispatcher flushes these on the next emission boundary\n * (spec \u00A79 allows either direct streaming or this queue pattern \u2014 we ship the\n * queue in Step 5.6 and the chat dispatcher will drain it in Step 5.10 when\n * the `mutation-preview-card` component registers). BC: callers that ignore\n * the field are unaffected.\n */\nexport interface AiUiPartQueue {\n /** Pushed by the mutation wrapper; drained by the dispatcher in order. */\n enqueue: (part: AiUiPart) => void\n drain: () => AiUiPart[]\n size: () => number\n}\n\nfunction createAiUiPartQueue(): AiUiPartQueue {\n const buffer: AiUiPart[] = []\n return {\n enqueue: (part) => {\n buffer.push(part)\n },\n drain: () => {\n const snapshot = buffer.slice()\n buffer.length = 0\n return snapshot\n },\n size: () => buffer.length,\n }\n}\n\nexport interface ResolvedAgentTools {\n agent: AiAgentDefinition\n tools: Record<string, Tool<unknown, unknown>>\n /**\n * Per-request UI-part queue the chat dispatcher drains between streamText\n * chunks (Step 5.10 contract). Always present; empty when no mutation-tool\n * calls fire during the turn.\n */\n uiPartQueue: AiUiPartQueue\n}\n\nfunction toPolicyAuthContext(ctx: AiChatRequestContext): {\n userFeatures: string[]\n isSuperAdmin: boolean\n} {\n return {\n userFeatures: ctx.features,\n isSuperAdmin: ctx.isSuperAdmin,\n }\n}\n\n/**\n * Sanitize a dotted tool name (e.g. `search.hybrid_search`) into a format\n * accepted by all major LLM providers. OpenAI requires tool names to match\n * `^[a-zA-Z0-9_-]+$`; dots are replaced with double underscores (`__`).\n * Anthropic and Google accept both formats, so this is safe across providers.\n */\nexport function sanitizeToolNameForModel(name: string): string {\n return name.replace(/\\./g, '__')\n}\n\nexport function desanitizeToolNameForDisplay(name: string): string {\n return name.replace(/__/g, '.')\n}\n\nfunction formatToolResult(result: unknown): string {\n if (result === null || result === undefined) return 'No result returned'\n if (typeof result === 'string') return result\n if (typeof result === 'number' || typeof result === 'boolean') return String(result)\n try {\n return JSON.stringify(result, null, 2)\n } catch {\n return String(result)\n }\n}\n\nfunction buildToolHandlerContext(\n ctx: AiChatRequestContext,\n container?: AwilixContainer,\n tool?: AiToolDefinition,\n): McpToolContext {\n return {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n container: (container ?? undefined) as unknown as McpToolContext['container'],\n userFeatures: ctx.features,\n isSuperAdmin: ctx.isSuperAdmin,\n ...(tool ? { tool } : {}),\n }\n}\n\ninterface MutationInterceptorOptions {\n agent: AiAgentDefinition\n tool: AiToolDefinition\n container: AwilixContainer\n ctx: AiChatRequestContext\n mutationPolicyOverride: AiAgentMutationPolicy | null\n conversationId: string | null\n uiPartQueue: AiUiPartQueue\n}\n\nfunction formatPendingActionToolResult(\n agent: AiAgentDefinition,\n tool: AiToolDefinition,\n pendingActionId: string,\n expiresAt: Date,\n): string {\n return formatToolResult({\n status: 'pending-confirmation',\n agentId: agent.id,\n toolName: tool.name,\n pendingActionId,\n expiresAt: expiresAt.toISOString(),\n message: `Awaiting user confirmation for mutation \"${tool.name}\". The action will NOT run until the user approves it.`,\n })\n}\n\n/**\n * Per-call gating decision for the destructive-confirm-required policy.\n * Honors a static boolean OR a predicate evaluated against the actual\n * tool-call args, so a multi-operation tool (e.g.\n * `customers.manage_deal_comment` with `operation: 'create' | 'update'\n * | 'delete'`) can gate only its destructive branches without being\n * split into multiple tools.\n */\nfunction resolveIsDestructive(\n isDestructive: boolean | ((input: unknown) => boolean) | undefined,\n args: unknown,\n): boolean {\n if (typeof isDestructive === 'function') {\n try {\n return isDestructive(args) === true\n } catch {\n // If the predicate throws (e.g. unexpected input shape), default to\n // gating \u2014 fail-safe for destructive policy.\n return true\n }\n }\n return isDestructive === true\n}\n\nfunction adaptToolToAiSdk(\n tool: AiToolDefinition,\n ctx: AiChatRequestContext,\n mutation: MutationInterceptorOptions | null,\n container?: AwilixContainer,\n effectiveMutationPolicy?: 'read-only' | 'confirm-required' | 'destructive-confirm-required',\n): Tool<unknown, unknown> {\n const safeSchema = toSafeZodSchema(tool.inputSchema as ZodType)\n const handlerContext = buildToolHandlerContext(ctx, container, tool)\n return dynamicTool({\n description: tool.description,\n inputSchema: safeSchema,\n execute: async (args: unknown) => {\n // Per-call gating decision. Under `destructive-confirm-required`,\n // tools with a predicate `isDestructive(args)` may flip per call \u2014\n // e.g. comment delete gates while comment create runs direct.\n const requiresApprovalNow = (() => {\n if (!mutation) return false\n if (effectiveMutationPolicy === 'confirm-required') return true\n if (effectiveMutationPolicy === 'destructive-confirm-required') {\n return resolveIsDestructive(mutation.tool.isDestructive, args)\n }\n return false\n })()\n if (mutation && requiresApprovalNow) {\n try {\n const toolCallArgs =\n args && typeof args === 'object' && !Array.isArray(args)\n ? { ...(args as Record<string, unknown>) }\n : {}\n const { uiPart, pendingAction } = await prepareMutation(\n {\n agent: mutation.agent,\n tool: mutation.tool,\n toolCallArgs,\n conversationId: mutation.conversationId,\n mutationPolicyOverride: mutation.mutationPolicyOverride,\n },\n {\n tenantId: mutation.ctx.tenantId,\n organizationId: mutation.ctx.organizationId,\n userId: mutation.ctx.userId,\n features: mutation.ctx.features,\n isSuperAdmin: mutation.ctx.isSuperAdmin,\n container: mutation.container,\n },\n )\n mutation.uiPartQueue.enqueue(uiPart)\n return formatPendingActionToolResult(\n mutation.agent,\n mutation.tool,\n pendingAction.id,\n pendingAction.expiresAt,\n )\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n throw new Error(\n `Tool \"${tool.name}\" could not be prepared for confirmation: ${message}`,\n )\n }\n }\n try {\n // Create a fresh container per tool call so the EM is never stale.\n const freshContainer = await createRequestContainer()\n const freshContext: McpToolContext = {\n ...handlerContext,\n container: freshContainer as unknown as McpToolContext['container'],\n }\n const { executeTool } = await import('./tool-executor')\n const execResult = await executeTool(tool.name, args, freshContext)\n if (!execResult.success) {\n throw new Error(execResult.error || 'Tool execution failed')\n }\n return formatToolResult(execResult.result)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n throw new Error(`Tool \"${tool.name}\" failed: ${message}`)\n }\n },\n })\n}\n\n/**\n * Resolves the agent's whitelisted tools into an AI SDK `tools` map, enforcing\n * the same policy gate as the HTTP dispatcher. Throws {@link AgentPolicyError}\n * on agent-level deny; tool-level denies are skipped with a logger warning\n * because the agent author whitelisted a tool the caller is not currently\n * permitted to execute (deterministic non-failure \u2014 the remaining tools still\n * reach the model).\n */\nexport async function resolveAiAgentTools(\n input: ResolveAiAgentToolsInput,\n): Promise<ResolvedAgentTools> {\n await loadAgentRegistry()\n // The agent registry alone is not enough: the policy gate below resolves\n // every allowlisted name against the TOOL registry, and rejects what it\n // cannot find as `tool_unknown`. Loading it here \u2014 rather than in the half\n // dozen entry points that happened to remember \u2014 is what makes an agent's\n // allowlist mean the same thing on every path.\n try {\n const { ensureModuleToolsLoaded } = await import('./tool-loader')\n await ensureModuleToolsLoaded()\n } catch (error) {\n // A registry that failed to load leaves the agent toolless, which the\n // caller already handles; it must not take the whole turn down.\n logger.error('Could not load module tools; agent will resolve to its known tools only', {\n agentId: input.agentId,\n err: error,\n })\n }\n\n const policyAuth = toPolicyAuthContext(input.authContext)\n const mutationPolicyOverride = input.mutationPolicyOverride ?? null\n const agentDecision = checkAgentPolicy({\n agentId: input.agentId,\n authContext: policyAuth,\n requestedExecutionMode: input.requestedExecutionMode ?? 'chat',\n mutationPolicyOverride,\n })\n if (!agentDecision.ok) {\n throw new AgentPolicyError(agentDecision.code, agentDecision.message)\n }\n\n const { agent } = agentDecision\n const tools: Record<string, Tool<unknown, unknown>> = {}\n const uiPartQueue = createAiUiPartQueue()\n const effectiveMutationPolicy = resolveEffectiveMutationPolicy(\n agent.mutationPolicy,\n mutationPolicyOverride,\n agent.id,\n )\n const canInterceptMutations =\n effectiveMutationPolicy !== 'read-only' && typeof input.container !== 'undefined'\n\n for (const toolName of agent.allowedTools) {\n const toolDecision = checkAgentPolicy({\n agentId: input.agentId,\n authContext: policyAuth,\n toolName,\n mutationPolicyOverride,\n })\n if (!toolDecision.ok) {\n logger.warn('Skipping tool for agent', { toolName, agentId: agent.id, reason: toolDecision.message })\n continue\n }\n\n const record = (toolDecision.tool ?? toolRegistry.getTool(toolName)) as\n | AiToolDefinition\n | undefined\n if (!record) {\n logger.warn('Tool vanished from registry between policy checks; skipping', { toolName })\n continue\n }\n\n try {\n // For any mutation tool whose policy isn't read-only, prepare the\n // interceptor options once. The actual gate-vs-run-direct decision\n // happens at call time inside `adaptToolToAiSdk` because under\n // `destructive-confirm-required` `isDestructive` may be a predicate\n // evaluated against the per-call args (e.g. a multi-operation\n // tool with `operation: 'create' | 'update' | 'delete'` gates only\n // the destructive branch).\n const mutationOptions: MutationInterceptorOptions | null =\n record.isMutation === true && canInterceptMutations && input.container\n ? {\n agent,\n tool: record,\n container: input.container,\n ctx: input.authContext,\n mutationPolicyOverride,\n conversationId: input.conversationId ?? null,\n uiPartQueue,\n }\n : null\n // Sanitize tool name for model API compatibility: OpenAI requires\n // names matching ^[a-zA-Z0-9_-]+$ (no dots). Replace dots with\n // double underscores so `search.hybrid_search` becomes\n // `search__hybrid_search`. The AI SDK uses the record key as the\n // tool name sent to the model; the original dotted name stays on\n // the `tool` object for logging and prepareMutation hashing.\n const modelSafeToolName = sanitizeToolNameForModel(toolName)\n tools[modelSafeToolName] = adaptToolToAiSdk(\n record,\n input.authContext,\n mutationOptions,\n input.container,\n effectiveMutationPolicy,\n )\n } catch (error) {\n logger.error('Failed to adapt tool for agent', { toolName, agentId: agent.id, err: error })\n }\n }\n\n return { agent, tools, uiPartQueue }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,mBAA8B;AAGvC,SAAS,8BAA8B;
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { dynamicTool, type Tool } from 'ai'\nimport type { AwilixContainer } from 'awilix'\nimport type { ZodType } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { AiAgentDefinition, AiAgentMutationPolicy } from './ai-agent-definition'\nimport type { AiChatRequestContext, AiUiPart } from './attachment-bridge-types'\nimport type { AiToolDefinition, McpToolContext } from './types'\nimport type {\n AiPendingActionExecutionResult,\n AiPendingActionStatus,\n} from './pending-action-types'\nimport { loadAgentRegistry } from './agent-registry'\nimport {\n checkAgentPolicy,\n resolveEffectiveMutationPolicy,\n type AgentPolicyDenyCode,\n} from './agent-policy'\nimport { toolRegistry } from './tool-registry'\nimport { toSafeZodSchema } from './schema-utils'\nimport { prepareMutation } from './prepare-mutation'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * Error thrown by `resolveAiAgentTools` (and downstream `runAiAgentText`) when\n * the agent-level policy check denies a request. Carries the structured deny\n * code so HTTP dispatchers can map it to a stable status and JSON body.\n */\nexport class AgentPolicyError extends Error {\n readonly code: AgentPolicyDenyCode\n\n constructor(code: AgentPolicyDenyCode, message: string) {\n super(message)\n this.name = 'AgentPolicyError'\n this.code = code\n }\n}\n\nexport interface ResolveAiAgentToolsInput {\n agentId: string\n authContext: AiChatRequestContext\n pageContext?: Record<string, unknown>\n attachmentIds?: string[]\n /**\n * Execution mode the caller intends to run the agent in. Defaults to\n * `'chat'` to preserve the existing chat dispatcher contract. Object-mode\n * callers (see `runAiAgentObject`) MUST pass `'object'` so the policy gate\n * can reject chat-only agents early with `execution_mode_not_supported`.\n */\n requestedExecutionMode?: 'chat' | 'object'\n /**\n * Optional tenant-scoped mutation-policy DOWNGRADE. When supplied, the\n * effective policy evaluated by `checkAgentPolicy` is the most restrictive\n * of `{ agent.mutationPolicy, mutationPolicyOverride }`. Escalation is\n * rejected at the route layer; this helper trusts callers to pass only\n * values produced by the override repository.\n */\n mutationPolicyOverride?: AiAgentMutationPolicy | null\n /**\n * DI container used by the `prepareMutation` tool-call wrapper (Step 5.6).\n * When present AND the agent's effective mutation policy is non-read-only,\n * `isMutation: true` tools are intercepted: the runtime creates an\n * `AiPendingAction` row and enqueues a `mutation-preview-card` UI part in\n * the returned {@link ResolvedAgentTools.uiPartQueue} instead of running the\n * tool's handler. When absent, mutation tools degrade-gracefully to the\n * pre-5.6 pass-through adapter \u2014 existing read-only agents are unaffected.\n */\n container?: AwilixContainer\n /**\n * Optional chat-turn correlation id used when hashing the\n * `AiPendingAction.idempotencyKey` so retries of the same mutation collapse\n * to a single row. The chat dispatcher supplies the OpenCode / AI SDK turn\n * id here; when omitted the hash falls back to `null` which still preserves\n * per-tenant/org uniqueness within the TTL window.\n */\n conversationId?: string | null\n}\n\n/**\n * Queue of UI parts the mutation-preview wrapper accumulates during a turn.\n * The chat/object dispatcher flushes these on the next emission boundary\n * (spec \u00A79 allows either direct streaming or this queue pattern \u2014 we ship the\n * queue in Step 5.6 and the chat dispatcher will drain it in Step 5.10 when\n * the `mutation-preview-card` component registers). BC: callers that ignore\n * the field are unaffected.\n */\nexport interface AiUiPartQueue {\n /** Pushed by the mutation wrapper; drained by the dispatcher in order. */\n enqueue: (part: AiUiPart) => void\n drain: () => AiUiPart[]\n size: () => number\n}\n\nfunction createAiUiPartQueue(): AiUiPartQueue {\n const buffer: AiUiPart[] = []\n return {\n enqueue: (part) => {\n buffer.push(part)\n },\n drain: () => {\n const snapshot = buffer.slice()\n buffer.length = 0\n return snapshot\n },\n size: () => buffer.length,\n }\n}\n\nexport interface ResolvedAgentTools {\n agent: AiAgentDefinition\n tools: Record<string, Tool<unknown, unknown>>\n /**\n * Per-request UI-part queue the chat dispatcher drains between streamText\n * chunks (Step 5.10 contract). Always present; empty when no mutation-tool\n * calls fire during the turn.\n */\n uiPartQueue: AiUiPartQueue\n}\n\nfunction toPolicyAuthContext(ctx: AiChatRequestContext): {\n userFeatures: string[]\n isSuperAdmin: boolean\n} {\n return {\n userFeatures: ctx.features,\n isSuperAdmin: ctx.isSuperAdmin,\n }\n}\n\n/**\n * Sanitize a dotted tool name (e.g. `search.hybrid_search`) into a format\n * accepted by all major LLM providers. OpenAI requires tool names to match\n * `^[a-zA-Z0-9_-]+$`; dots are replaced with double underscores (`__`).\n * Anthropic and Google accept both formats, so this is safe across providers.\n */\nexport function sanitizeToolNameForModel(name: string): string {\n return name.replace(/\\./g, '__')\n}\n\nexport function desanitizeToolNameForDisplay(name: string): string {\n return name.replace(/__/g, '.')\n}\n\nfunction formatToolResult(result: unknown): string {\n if (result === null || result === undefined) return 'No result returned'\n if (typeof result === 'string') return result\n if (typeof result === 'number' || typeof result === 'boolean') return String(result)\n try {\n return JSON.stringify(result, null, 2)\n } catch {\n return String(result)\n }\n}\n\nfunction buildToolHandlerContext(\n ctx: AiChatRequestContext,\n container?: AwilixContainer,\n tool?: AiToolDefinition,\n): McpToolContext {\n return {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n container: (container ?? undefined) as unknown as McpToolContext['container'],\n userFeatures: ctx.features,\n isSuperAdmin: ctx.isSuperAdmin,\n ...(tool ? { tool } : {}),\n }\n}\n\ninterface MutationInterceptorOptions {\n agent: AiAgentDefinition\n tool: AiToolDefinition\n container: AwilixContainer\n ctx: AiChatRequestContext\n mutationPolicyOverride: AiAgentMutationPolicy | null\n conversationId: string | null\n uiPartQueue: AiUiPartQueue\n}\n\nfunction formatPendingActionToolResult(\n agent: AiAgentDefinition,\n tool: AiToolDefinition,\n pendingActionId: string,\n expiresAt: Date,\n reused: boolean,\n status: AiPendingActionStatus,\n executionResult: AiPendingActionExecutionResult | null,\n): string {\n // A reused `confirmed`/`executing` row means this exact mutation already\n // ran (or is running) under the same idempotency key \u2014 tell the model so\n // it does not resubmit, instead of always claiming a fresh approval is\n // pending (issue #7550).\n if (reused && (status === 'confirmed' || status === 'executing')) {\n const resultStatus = status === 'executing' ? 'executing' : 'already-executed'\n return formatToolResult({\n status: resultStatus,\n agentId: agent.id,\n toolName: tool.name,\n pendingActionId,\n executionResult: executionResult ?? null,\n message:\n resultStatus === 'executing'\n ? `Mutation \"${tool.name}\" is already executing from a prior confirmed call. Do NOT submit it again; wait for the result.`\n : `Mutation \"${tool.name}\" already ran for this input (idempotency key reused). Do NOT submit it again \u2014 see executionResult for the prior outcome.`,\n })\n }\n return formatToolResult({\n status: 'pending-confirmation',\n agentId: agent.id,\n toolName: tool.name,\n pendingActionId,\n expiresAt: expiresAt.toISOString(),\n message: `Awaiting user confirmation for mutation \"${tool.name}\". The action will NOT run until the user approves it.`,\n })\n}\n\n/**\n * Per-call gating decision for the destructive-confirm-required policy.\n * Honors a static boolean OR a predicate evaluated against the actual\n * tool-call args, so a multi-operation tool (e.g.\n * `customers.manage_deal_comment` with `operation: 'create' | 'update'\n * | 'delete'`) can gate only its destructive branches without being\n * split into multiple tools.\n */\nfunction resolveIsDestructive(\n isDestructive: boolean | ((input: unknown) => boolean) | undefined,\n args: unknown,\n): boolean {\n if (typeof isDestructive === 'function') {\n try {\n return isDestructive(args) === true\n } catch {\n // If the predicate throws (e.g. unexpected input shape), default to\n // gating \u2014 fail-safe for destructive policy.\n return true\n }\n }\n return isDestructive === true\n}\n\nfunction adaptToolToAiSdk(\n tool: AiToolDefinition,\n ctx: AiChatRequestContext,\n mutation: MutationInterceptorOptions | null,\n container?: AwilixContainer,\n effectiveMutationPolicy?: 'read-only' | 'confirm-required' | 'destructive-confirm-required',\n): Tool<unknown, unknown> {\n const safeSchema = toSafeZodSchema(tool.inputSchema as ZodType)\n const handlerContext = buildToolHandlerContext(ctx, container, tool)\n return dynamicTool({\n description: tool.description,\n inputSchema: safeSchema,\n execute: async (args: unknown) => {\n // Per-call gating decision. Under `destructive-confirm-required`,\n // tools with a predicate `isDestructive(args)` may flip per call \u2014\n // e.g. comment delete gates while comment create runs direct.\n const requiresApprovalNow = (() => {\n if (!mutation) return false\n if (effectiveMutationPolicy === 'confirm-required') return true\n if (effectiveMutationPolicy === 'destructive-confirm-required') {\n return resolveIsDestructive(mutation.tool.isDestructive, args)\n }\n return false\n })()\n if (mutation && requiresApprovalNow) {\n try {\n const toolCallArgs =\n args && typeof args === 'object' && !Array.isArray(args)\n ? { ...(args as Record<string, unknown>) }\n : {}\n const { uiPart, pendingAction, reused } = await prepareMutation(\n {\n agent: mutation.agent,\n tool: mutation.tool,\n toolCallArgs,\n conversationId: mutation.conversationId,\n mutationPolicyOverride: mutation.mutationPolicyOverride,\n },\n {\n tenantId: mutation.ctx.tenantId,\n organizationId: mutation.ctx.organizationId,\n userId: mutation.ctx.userId,\n features: mutation.ctx.features,\n isSuperAdmin: mutation.ctx.isSuperAdmin,\n container: mutation.container,\n },\n )\n mutation.uiPartQueue.enqueue(uiPart)\n return formatPendingActionToolResult(\n mutation.agent,\n mutation.tool,\n pendingAction.id,\n pendingAction.expiresAt,\n reused,\n pendingAction.status,\n pendingAction.executionResult ?? null,\n )\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n throw new Error(\n `Tool \"${tool.name}\" could not be prepared for confirmation: ${message}`,\n )\n }\n }\n try {\n // Create a fresh container per tool call so the EM is never stale.\n const freshContainer = await createRequestContainer()\n const freshContext: McpToolContext = {\n ...handlerContext,\n container: freshContainer as unknown as McpToolContext['container'],\n }\n const { executeTool } = await import('./tool-executor')\n const execResult = await executeTool(tool.name, args, freshContext)\n if (!execResult.success) {\n throw new Error(execResult.error || 'Tool execution failed')\n }\n return formatToolResult(execResult.result)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n throw new Error(`Tool \"${tool.name}\" failed: ${message}`)\n }\n },\n })\n}\n\n/**\n * Resolves the agent's whitelisted tools into an AI SDK `tools` map, enforcing\n * the same policy gate as the HTTP dispatcher. Throws {@link AgentPolicyError}\n * on agent-level deny; tool-level denies are skipped with a logger warning\n * because the agent author whitelisted a tool the caller is not currently\n * permitted to execute (deterministic non-failure \u2014 the remaining tools still\n * reach the model).\n */\nexport async function resolveAiAgentTools(\n input: ResolveAiAgentToolsInput,\n): Promise<ResolvedAgentTools> {\n await loadAgentRegistry()\n // The agent registry alone is not enough: the policy gate below resolves\n // every allowlisted name against the TOOL registry, and rejects what it\n // cannot find as `tool_unknown`. Loading it here \u2014 rather than in the half\n // dozen entry points that happened to remember \u2014 is what makes an agent's\n // allowlist mean the same thing on every path.\n try {\n const { ensureModuleToolsLoaded } = await import('./tool-loader')\n await ensureModuleToolsLoaded()\n } catch (error) {\n // A registry that failed to load leaves the agent toolless, which the\n // caller already handles; it must not take the whole turn down.\n logger.error('Could not load module tools; agent will resolve to its known tools only', {\n agentId: input.agentId,\n err: error,\n })\n }\n\n const policyAuth = toPolicyAuthContext(input.authContext)\n const mutationPolicyOverride = input.mutationPolicyOverride ?? null\n const agentDecision = checkAgentPolicy({\n agentId: input.agentId,\n authContext: policyAuth,\n requestedExecutionMode: input.requestedExecutionMode ?? 'chat',\n mutationPolicyOverride,\n })\n if (!agentDecision.ok) {\n throw new AgentPolicyError(agentDecision.code, agentDecision.message)\n }\n\n const { agent } = agentDecision\n const tools: Record<string, Tool<unknown, unknown>> = {}\n const uiPartQueue = createAiUiPartQueue()\n const effectiveMutationPolicy = resolveEffectiveMutationPolicy(\n agent.mutationPolicy,\n mutationPolicyOverride,\n agent.id,\n )\n const canInterceptMutations =\n effectiveMutationPolicy !== 'read-only' && typeof input.container !== 'undefined'\n\n for (const toolName of agent.allowedTools) {\n const toolDecision = checkAgentPolicy({\n agentId: input.agentId,\n authContext: policyAuth,\n toolName,\n mutationPolicyOverride,\n })\n if (!toolDecision.ok) {\n logger.warn('Skipping tool for agent', { toolName, agentId: agent.id, reason: toolDecision.message })\n continue\n }\n\n const record = (toolDecision.tool ?? toolRegistry.getTool(toolName)) as\n | AiToolDefinition\n | undefined\n if (!record) {\n logger.warn('Tool vanished from registry between policy checks; skipping', { toolName })\n continue\n }\n\n try {\n // For any mutation tool whose policy isn't read-only, prepare the\n // interceptor options once. The actual gate-vs-run-direct decision\n // happens at call time inside `adaptToolToAiSdk` because under\n // `destructive-confirm-required` `isDestructive` may be a predicate\n // evaluated against the per-call args (e.g. a multi-operation\n // tool with `operation: 'create' | 'update' | 'delete'` gates only\n // the destructive branch).\n const mutationOptions: MutationInterceptorOptions | null =\n record.isMutation === true && canInterceptMutations && input.container\n ? {\n agent,\n tool: record,\n container: input.container,\n ctx: input.authContext,\n mutationPolicyOverride,\n conversationId: input.conversationId ?? null,\n uiPartQueue,\n }\n : null\n // Sanitize tool name for model API compatibility: OpenAI requires\n // names matching ^[a-zA-Z0-9_-]+$ (no dots). Replace dots with\n // double underscores so `search.hybrid_search` becomes\n // `search__hybrid_search`. The AI SDK uses the record key as the\n // tool name sent to the model; the original dotted name stays on\n // the `tool` object for logging and prepareMutation hashing.\n const modelSafeToolName = sanitizeToolNameForModel(toolName)\n tools[modelSafeToolName] = adaptToolToAiSdk(\n record,\n input.authContext,\n mutationOptions,\n input.container,\n effectiveMutationPolicy,\n )\n } catch (error) {\n logger.error('Failed to adapt tool for agent', { toolName, agentId: agent.id, err: error })\n }\n }\n\n return { agent, tools, uiPartQueue }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -296,7 +296,7 @@ async function prepareMutation(input, ctx) {
|
|
|
296
296
|
toolName: tool.name,
|
|
297
297
|
normalizedInput
|
|
298
298
|
});
|
|
299
|
-
const pendingAction = await repo.create(
|
|
299
|
+
const { row: pendingAction, reused } = await repo.create(
|
|
300
300
|
{
|
|
301
301
|
agentId: agent.id,
|
|
302
302
|
toolName: tool.name,
|
|
@@ -328,7 +328,7 @@ async function prepareMutation(input, ctx) {
|
|
|
328
328
|
...sideEffectsSummary ? { sideEffectsSummary } : {}
|
|
329
329
|
}
|
|
330
330
|
};
|
|
331
|
-
return { uiPart, pendingAction };
|
|
331
|
+
return { uiPart, pendingAction, reused };
|
|
332
332
|
}
|
|
333
333
|
const MUTATION_PREVIEW_CARD_COMPONENT = MUTATION_PREVIEW_CARD_COMPONENT_ID;
|
|
334
334
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/ai_assistant/lib/prepare-mutation.ts"],
|
|
4
|
-
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { createHash } from 'node:crypto'\nimport type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AiAgentDefinition, AiAgentMutationPolicy } from './ai-agent-definition'\nimport type { AiChatRequestContext, AiUiPart } from './attachment-bridge-types'\nimport type {\n AiToolDefinition,\n AiToolFieldDiffDisplayHints,\n AiToolLoadBeforeRecord,\n AiToolLoadBeforeSingleRecord,\n McpToolContext,\n} from './types'\nimport { resolveEffectiveMutationPolicy } from './agent-policy'\nimport { AiPendingActionRepository } from '../data/repositories/AiPendingActionRepository'\nimport type { AiPendingAction } from '../data/entities'\nimport type {\n AiPendingActionFieldDiff,\n AiPendingActionRecordDiff,\n} from './pending-action-types'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * Structured error raised by {@link prepareMutation}. Callers (today the\n * agent-runtime tool wrapper installed by `resolveAiAgentTools`) turn this\n * into a tool-call failure that the model surfaces back to the user without\n * leaking internals. The runtime NEVER reaches this helper when the agent\n * is declared read-only \u2014 the policy gate rejects the tool call upstream \u2014\n * but we keep the fail-closed check as a defensive guard.\n */\nexport class AiMutationPreparationError extends Error {\n constructor(\n public readonly code:\n | 'not_a_mutation_tool'\n | 'read_only_agent'\n | 'tenant_scope_missing'\n | 'container_missing'\n | 'em_missing',\n message: string,\n ) {\n super(message)\n this.name = 'AiMutationPreparationError'\n }\n}\n\nexport interface PrepareMutationInput {\n agent: AiAgentDefinition\n tool: AiToolDefinition\n toolCallArgs: Record<string, unknown>\n conversationId?: string | null\n /**\n * Optional downgrade the caller already resolved (mirror of\n * `resolveAiAgentTools({ mutationPolicyOverride })`). When omitted, the\n * agent's code-declared policy stands alone.\n */\n mutationPolicyOverride?: AiAgentMutationPolicy | null\n /**\n * Deterministic clock hook for tests. Defaults to `new Date()`.\n */\n now?: Date\n}\n\nexport interface PrepareMutationContext extends AiChatRequestContext {\n container: AwilixContainer\n}\n\nexport interface PrepareMutationResult {\n uiPart: AiUiPart\n pendingAction: AiPendingAction\n}\n\nconst MUTATION_PREVIEW_CARD_COMPONENT_ID = 'mutation-preview-card'\n\nconst NO_RESOLVER_SIDE_EFFECTS_MESSAGE =\n 'Tool did not declare a field-diff resolver; action will proceed without a preview.'\n\nfunction assertTenantScope(ctx: PrepareMutationContext): string {\n if (!ctx.tenantId) {\n throw new AiMutationPreparationError(\n 'tenant_scope_missing',\n 'prepareMutation requires a tenant-scoped request context.',\n )\n }\n return ctx.tenantId\n}\n\nfunction resolveEm(container: AwilixContainer): EntityManager {\n if (!container) {\n throw new AiMutationPreparationError(\n 'container_missing',\n 'prepareMutation requires an Awilix container to resolve the EntityManager.',\n )\n }\n let em: EntityManager | null = null\n try {\n em = container.resolve<EntityManager>('em')\n } catch {\n em = null\n }\n if (!em) {\n throw new AiMutationPreparationError(\n 'em_missing',\n 'prepareMutation could not resolve \"em\" from the container.',\n )\n }\n return em\n}\n\nfunction toolHandlerContext(ctx: PrepareMutationContext): McpToolContext {\n return {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n container: ctx.container,\n userFeatures: ctx.features,\n isSuperAdmin: ctx.isSuperAdmin,\n }\n}\n\nfunction safeStringify(value: unknown): string {\n const seen = new WeakSet<object>()\n return JSON.stringify(value, (_key, raw) => {\n if (raw && typeof raw === 'object') {\n if (seen.has(raw as object)) return '[Circular]'\n seen.add(raw as object)\n const entries = Object.entries(raw as Record<string, unknown>)\n entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n return entries.reduce<Record<string, unknown>>((acc, [k, v]) => {\n acc[k] = v\n return acc\n }, {})\n }\n return raw\n })\n}\n\n/**\n * Hashes `(tenantId, orgId, agentId, conversationId, toolName, normalizedInput)`\n * into a stable SHA-256 digest so that retries of the same tool call with the\n * same payload collapse to a single `AiPendingAction` row inside the TTL\n * window. The input is normalized through `safeStringify` to make object key\n * order irrelevant (spec \u00A78 rule `idempotencyKey prevents double-submission`).\n * Attachments are NOT included \u2014 the attachment set is captured separately on\n * the pending row so that re-uploading the same file set with a different\n * tool-call object never accidentally collides.\n */\nexport function computeMutationIdempotencyKey(input: {\n tenantId: string\n organizationId: string | null\n agentId: string\n conversationId: string | null\n toolName: string\n normalizedInput: Record<string, unknown>\n}): string {\n const canonical = safeStringify({\n tenant: input.tenantId,\n org: input.organizationId ?? null,\n agent: input.agentId,\n conversation: input.conversationId ?? null,\n tool: input.toolName,\n input: input.normalizedInput ?? {},\n })\n return createHash('sha256').update(canonical).digest('hex')\n}\n\nfunction computeFieldDiff(\n before: Record<string, unknown>,\n after: Record<string, unknown>,\n display?: AiToolFieldDiffDisplayHints,\n options?: { keys?: Iterable<string> },\n): AiPendingActionFieldDiff[] {\n const diff: AiPendingActionFieldDiff[] = []\n const keys = options?.keys\n ? new Set<string>(options.keys)\n : new Set<string>([\n ...Object.keys(before ?? {}),\n ...Object.keys(after ?? {}),\n ])\n for (const field of keys) {\n const beforeValue = before ? before[field] : undefined\n const afterValue = after ? after[field] : undefined\n if (!Object.is(beforeValue, afterValue) && safeStringify(beforeValue) !== safeStringify(afterValue)) {\n const fieldLabel = display?.fieldLabels?.[field]\n const beforeDisplay = display?.before?.[field]\n const afterDisplay = display?.after?.[field]\n diff.push({\n field,\n ...(fieldLabel !== undefined ? { fieldLabel } : {}),\n before: beforeValue,\n after: afterValue,\n ...(beforeDisplay !== undefined ? { beforeDisplay } : {}),\n ...(afterDisplay !== undefined ? { afterDisplay } : {}),\n })\n }\n }\n return diff\n}\n\nfunction extractPatchFromArgs(\n args: Record<string, unknown>,\n): Record<string, unknown> {\n const raw = args?.patch\n if (raw && typeof raw === 'object' && !Array.isArray(raw)) {\n return raw as Record<string, unknown>\n }\n // Fall back: treat the whole args object (minus well-known envelope keys)\n // as the patch. This preserves compatibility with tools whose schema is\n // flat (`{ productId, name }`) rather than nested (`{ productId, patch }`).\n const envelope = new Set([\n 'id',\n 'recordId',\n 'records',\n 'attachmentIds',\n '_sessionToken',\n ])\n const reduced: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(args ?? {})) {\n if (envelope.has(key)) continue\n reduced[key] = value\n }\n return reduced\n}\n\nfunction matchBatchPatch(\n args: Record<string, unknown>,\n recordId: string,\n): Record<string, unknown> {\n const rawList = args?.records\n if (Array.isArray(rawList)) {\n const match = rawList.find((entry) => {\n if (!entry || typeof entry !== 'object') return false\n const candidate = entry as Record<string, unknown>\n return candidate.recordId === recordId || candidate.id === recordId\n })\n if (match && typeof match === 'object') {\n const patch = (match as Record<string, unknown>).patch\n if (patch && typeof patch === 'object' && !Array.isArray(patch)) {\n return patch as Record<string, unknown>\n }\n const envelope = new Set(['recordId', 'id'])\n const reduced: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(match as Record<string, unknown>)) {\n if (envelope.has(key)) continue\n reduced[key] = value\n }\n return reduced\n }\n }\n return {}\n}\n\nfunction fallbackPatchDiffKeys(\n patch: Record<string, unknown>,\n before: Record<string, unknown>,\n): string[] {\n return Object.keys(patch ?? {}).filter((key) =>\n Object.prototype.hasOwnProperty.call(before ?? {}, key),\n )\n}\n\nfunction normalizeAttachmentIds(args: Record<string, unknown>): string[] {\n const raw = args?.attachmentIds\n if (!Array.isArray(raw)) return []\n return raw.filter((value): value is string => typeof value === 'string' && value.length > 0)\n}\n\nasync function buildSingleRecordDiff(\n tool: AiToolDefinition,\n input: PrepareMutationInput,\n ctx: PrepareMutationContext,\n): Promise<{\n fieldDiff: AiPendingActionFieldDiff[]\n targetEntityType: string | null\n targetRecordId: string | null\n recordVersion: string | null\n sideEffectsSummary: string | null\n}> {\n const resolver = tool.loadBeforeRecord\n if (!resolver) {\n logger.warn('prepareMutation: tool declared isMutation=true but no loadBeforeRecord resolver; shipping empty fieldDiff', { toolName: tool.name })\n return {\n fieldDiff: [],\n targetEntityType: null,\n targetRecordId: null,\n recordVersion: null,\n sideEffectsSummary: NO_RESOLVER_SIDE_EFFECTS_MESSAGE,\n }\n }\n const handlerContext = toolHandlerContext(ctx)\n const before: AiToolLoadBeforeSingleRecord | null = await resolver(\n input.toolCallArgs as never,\n handlerContext,\n )\n if (!before) {\n return {\n fieldDiff: [],\n targetEntityType: null,\n targetRecordId: null,\n recordVersion: null,\n sideEffectsSummary: null,\n }\n }\n const hasExplicitAfter = before.after != null\n const patch = before.after ?? extractPatchFromArgs(input.toolCallArgs)\n const fieldDiff = computeFieldDiff(\n before.before,\n patch,\n before.display,\n hasExplicitAfter ? undefined : { keys: fallbackPatchDiffKeys(patch, before.before) },\n )\n return {\n fieldDiff,\n targetEntityType: before.entityType,\n targetRecordId: before.recordId,\n recordVersion: before.recordVersion,\n sideEffectsSummary: null,\n }\n}\n\nasync function buildBatchRecords(\n tool: AiToolDefinition,\n input: PrepareMutationInput,\n ctx: PrepareMutationContext,\n): Promise<{\n records: AiPendingActionRecordDiff[] | null\n targetEntityType: string | null\n sideEffectsSummary: string | null\n}> {\n const resolver = tool.loadBeforeRecords\n if (!resolver) {\n logger.warn('prepareMutation: bulk tool declared isMutation=true but no loadBeforeRecords resolver; shipping empty records[]', { toolName: tool.name })\n return {\n records: null,\n targetEntityType: null,\n sideEffectsSummary: NO_RESOLVER_SIDE_EFFECTS_MESSAGE,\n }\n }\n const handlerContext = toolHandlerContext(ctx)\n const rows: AiToolLoadBeforeRecord[] = await resolver(\n input.toolCallArgs as never,\n handlerContext,\n )\n if (!Array.isArray(rows) || rows.length === 0) {\n return {\n records: null,\n targetEntityType: null,\n sideEffectsSummary: null,\n }\n }\n const diffs: AiPendingActionRecordDiff[] = rows.map((row) => {\n const hasExplicitAfter = row.after != null\n const patch = row.after ?? matchBatchPatch(input.toolCallArgs, row.recordId)\n return {\n recordId: row.recordId,\n entityType: row.entityType,\n label: row.label,\n fieldDiff: computeFieldDiff(\n row.before,\n patch,\n row.display,\n hasExplicitAfter ? undefined : { keys: fallbackPatchDiffKeys(patch, row.before) },\n ),\n recordVersion: row.recordVersion ?? null,\n }\n })\n const [firstEntity] = rows\n return {\n records: diffs,\n targetEntityType: firstEntity ? firstEntity.entityType : null,\n sideEffectsSummary: null,\n }\n}\n\n/**\n * Intercepts a mutation tool call and turns it into an `AiPendingAction` +\n * `mutation-preview-card` UI part (spec Phase 3 WS-C \u00A79). The caller MUST\n * have already confirmed the agent's effective `mutationPolicy` is NOT\n * `read-only`; this helper repeats the check defensively because skipping it\n * would be a policy-bypass.\n *\n * The tool handler is NEVER invoked by this function \u2014 the write is\n * short-circuited and only runs from the Step 5.8 confirm route. See the\n * unit test `does not call the tool handler` for the guard.\n */\nexport async function prepareMutation(\n input: PrepareMutationInput,\n ctx: PrepareMutationContext,\n): Promise<PrepareMutationResult> {\n const { agent, tool } = input\n if (tool.isMutation !== true) {\n throw new AiMutationPreparationError(\n 'not_a_mutation_tool',\n `Tool \"${tool.name}\" is not a mutation tool; prepareMutation should not be invoked.`,\n )\n }\n const effectivePolicy = resolveEffectiveMutationPolicy(\n agent.mutationPolicy,\n input.mutationPolicyOverride ?? null,\n agent.id,\n )\n if (effectivePolicy === 'read-only') {\n throw new AiMutationPreparationError(\n 'read_only_agent',\n `Agent \"${agent.id}\" has effective mutationPolicy=read-only; mutation tool \"${tool.name}\" cannot be prepared.`,\n )\n }\n\n const tenantId = assertTenantScope(ctx)\n const em = resolveEm(ctx.container)\n const repo = new AiPendingActionRepository(em)\n\n const isBulk = tool.isBulk === true\n let fieldDiff: AiPendingActionFieldDiff[] = []\n let records: AiPendingActionRecordDiff[] | null = null\n let targetEntityType: string | null = null\n let targetRecordId: string | null = null\n let recordVersion: string | null = null\n let sideEffectsSummary: string | null = null\n\n if (isBulk) {\n const batch = await buildBatchRecords(tool, input, ctx)\n records = batch.records\n targetEntityType = batch.targetEntityType\n sideEffectsSummary = batch.sideEffectsSummary\n } else {\n const single = await buildSingleRecordDiff(tool, input, ctx)\n fieldDiff = single.fieldDiff\n targetEntityType = single.targetEntityType\n targetRecordId = single.targetRecordId\n recordVersion = single.recordVersion\n sideEffectsSummary = single.sideEffectsSummary\n }\n\n const normalizedInput = input.toolCallArgs ?? {}\n const conversationId = input.conversationId ?? null\n const idempotencyKey = computeMutationIdempotencyKey({\n tenantId,\n organizationId: ctx.organizationId ?? null,\n agentId: agent.id,\n conversationId,\n toolName: tool.name,\n normalizedInput,\n })\n\n const pendingAction = await repo.create(\n {\n agentId: agent.id,\n toolName: tool.name,\n idempotencyKey,\n createdByUserId: ctx.userId,\n normalizedInput,\n conversationId,\n targetEntityType,\n targetRecordId,\n fieldDiff,\n records,\n sideEffectsSummary,\n recordVersion,\n attachmentIds: normalizeAttachmentIds(normalizedInput),\n now: input.now,\n },\n {\n tenantId,\n organizationId: ctx.organizationId ?? null,\n userId: ctx.userId,\n },\n )\n\n const uiPart: AiUiPart = {\n componentId: MUTATION_PREVIEW_CARD_COMPONENT_ID,\n props: {\n pendingActionId: pendingAction.id,\n expiresAt: pendingAction.expiresAt.toISOString(),\n ...(records ? { records } : { fieldDiff }),\n ...(sideEffectsSummary ? { sideEffectsSummary } : {}),\n },\n }\n\n return { uiPart, pendingAction }\n}\n\nexport const MUTATION_PREVIEW_CARD_COMPONENT = MUTATION_PREVIEW_CARD_COMPONENT_ID\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAY3B,SAAS,sCAAsC;AAC/C,SAAS,iCAAiC;AAO1C,MAAM,SAAS,aAAa,cAAc;AAUnC,MAAM,mCAAmC,MAAM;AAAA,EACpD,YACkB,MAMhB,SACA;AACA,UAAM,OAAO;AARG;AAShB,SAAK,OAAO;AAAA,EACd;AACF;
|
|
4
|
+
"sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { createHash } from 'node:crypto'\nimport type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AiAgentDefinition, AiAgentMutationPolicy } from './ai-agent-definition'\nimport type { AiChatRequestContext, AiUiPart } from './attachment-bridge-types'\nimport type {\n AiToolDefinition,\n AiToolFieldDiffDisplayHints,\n AiToolLoadBeforeRecord,\n AiToolLoadBeforeSingleRecord,\n McpToolContext,\n} from './types'\nimport { resolveEffectiveMutationPolicy } from './agent-policy'\nimport { AiPendingActionRepository } from '../data/repositories/AiPendingActionRepository'\nimport type { AiPendingAction } from '../data/entities'\nimport type {\n AiPendingActionFieldDiff,\n AiPendingActionRecordDiff,\n} from './pending-action-types'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * Structured error raised by {@link prepareMutation}. Callers (today the\n * agent-runtime tool wrapper installed by `resolveAiAgentTools`) turn this\n * into a tool-call failure that the model surfaces back to the user without\n * leaking internals. The runtime NEVER reaches this helper when the agent\n * is declared read-only \u2014 the policy gate rejects the tool call upstream \u2014\n * but we keep the fail-closed check as a defensive guard.\n */\nexport class AiMutationPreparationError extends Error {\n constructor(\n public readonly code:\n | 'not_a_mutation_tool'\n | 'read_only_agent'\n | 'tenant_scope_missing'\n | 'container_missing'\n | 'em_missing',\n message: string,\n ) {\n super(message)\n this.name = 'AiMutationPreparationError'\n }\n}\n\nexport interface PrepareMutationInput {\n agent: AiAgentDefinition\n tool: AiToolDefinition\n toolCallArgs: Record<string, unknown>\n conversationId?: string | null\n /**\n * Optional downgrade the caller already resolved (mirror of\n * `resolveAiAgentTools({ mutationPolicyOverride })`). When omitted, the\n * agent's code-declared policy stands alone.\n */\n mutationPolicyOverride?: AiAgentMutationPolicy | null\n /**\n * Deterministic clock hook for tests. Defaults to `new Date()`.\n */\n now?: Date\n}\n\nexport interface PrepareMutationContext extends AiChatRequestContext {\n container: AwilixContainer\n}\n\nexport interface PrepareMutationResult {\n uiPart: AiUiPart\n pendingAction: AiPendingAction\n /**\n * True when `pendingAction` is an existing row the repository returned\n * as-is (still `pending`, or `confirmed`/`executing` because the\n * mutation already ran) instead of a freshly inserted row.\n */\n reused: boolean\n}\n\nconst MUTATION_PREVIEW_CARD_COMPONENT_ID = 'mutation-preview-card'\n\nconst NO_RESOLVER_SIDE_EFFECTS_MESSAGE =\n 'Tool did not declare a field-diff resolver; action will proceed without a preview.'\n\nfunction assertTenantScope(ctx: PrepareMutationContext): string {\n if (!ctx.tenantId) {\n throw new AiMutationPreparationError(\n 'tenant_scope_missing',\n 'prepareMutation requires a tenant-scoped request context.',\n )\n }\n return ctx.tenantId\n}\n\nfunction resolveEm(container: AwilixContainer): EntityManager {\n if (!container) {\n throw new AiMutationPreparationError(\n 'container_missing',\n 'prepareMutation requires an Awilix container to resolve the EntityManager.',\n )\n }\n let em: EntityManager | null = null\n try {\n em = container.resolve<EntityManager>('em')\n } catch {\n em = null\n }\n if (!em) {\n throw new AiMutationPreparationError(\n 'em_missing',\n 'prepareMutation could not resolve \"em\" from the container.',\n )\n }\n return em\n}\n\nfunction toolHandlerContext(ctx: PrepareMutationContext): McpToolContext {\n return {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n container: ctx.container,\n userFeatures: ctx.features,\n isSuperAdmin: ctx.isSuperAdmin,\n }\n}\n\nfunction safeStringify(value: unknown): string {\n const seen = new WeakSet<object>()\n return JSON.stringify(value, (_key, raw) => {\n if (raw && typeof raw === 'object') {\n if (seen.has(raw as object)) return '[Circular]'\n seen.add(raw as object)\n const entries = Object.entries(raw as Record<string, unknown>)\n entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n return entries.reduce<Record<string, unknown>>((acc, [k, v]) => {\n acc[k] = v\n return acc\n }, {})\n }\n return raw\n })\n}\n\n/**\n * Hashes `(tenantId, orgId, agentId, conversationId, toolName, normalizedInput)`\n * into a stable SHA-256 digest so that retries of the same tool call with the\n * same payload collapse to a single `AiPendingAction` row inside the TTL\n * window. The input is normalized through `safeStringify` to make object key\n * order irrelevant (spec \u00A78 rule `idempotencyKey prevents double-submission`).\n * Attachments are NOT included \u2014 the attachment set is captured separately on\n * the pending row so that re-uploading the same file set with a different\n * tool-call object never accidentally collides.\n */\nexport function computeMutationIdempotencyKey(input: {\n tenantId: string\n organizationId: string | null\n agentId: string\n conversationId: string | null\n toolName: string\n normalizedInput: Record<string, unknown>\n}): string {\n const canonical = safeStringify({\n tenant: input.tenantId,\n org: input.organizationId ?? null,\n agent: input.agentId,\n conversation: input.conversationId ?? null,\n tool: input.toolName,\n input: input.normalizedInput ?? {},\n })\n return createHash('sha256').update(canonical).digest('hex')\n}\n\nfunction computeFieldDiff(\n before: Record<string, unknown>,\n after: Record<string, unknown>,\n display?: AiToolFieldDiffDisplayHints,\n options?: { keys?: Iterable<string> },\n): AiPendingActionFieldDiff[] {\n const diff: AiPendingActionFieldDiff[] = []\n const keys = options?.keys\n ? new Set<string>(options.keys)\n : new Set<string>([\n ...Object.keys(before ?? {}),\n ...Object.keys(after ?? {}),\n ])\n for (const field of keys) {\n const beforeValue = before ? before[field] : undefined\n const afterValue = after ? after[field] : undefined\n if (!Object.is(beforeValue, afterValue) && safeStringify(beforeValue) !== safeStringify(afterValue)) {\n const fieldLabel = display?.fieldLabels?.[field]\n const beforeDisplay = display?.before?.[field]\n const afterDisplay = display?.after?.[field]\n diff.push({\n field,\n ...(fieldLabel !== undefined ? { fieldLabel } : {}),\n before: beforeValue,\n after: afterValue,\n ...(beforeDisplay !== undefined ? { beforeDisplay } : {}),\n ...(afterDisplay !== undefined ? { afterDisplay } : {}),\n })\n }\n }\n return diff\n}\n\nfunction extractPatchFromArgs(\n args: Record<string, unknown>,\n): Record<string, unknown> {\n const raw = args?.patch\n if (raw && typeof raw === 'object' && !Array.isArray(raw)) {\n return raw as Record<string, unknown>\n }\n // Fall back: treat the whole args object (minus well-known envelope keys)\n // as the patch. This preserves compatibility with tools whose schema is\n // flat (`{ productId, name }`) rather than nested (`{ productId, patch }`).\n const envelope = new Set([\n 'id',\n 'recordId',\n 'records',\n 'attachmentIds',\n '_sessionToken',\n ])\n const reduced: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(args ?? {})) {\n if (envelope.has(key)) continue\n reduced[key] = value\n }\n return reduced\n}\n\nfunction matchBatchPatch(\n args: Record<string, unknown>,\n recordId: string,\n): Record<string, unknown> {\n const rawList = args?.records\n if (Array.isArray(rawList)) {\n const match = rawList.find((entry) => {\n if (!entry || typeof entry !== 'object') return false\n const candidate = entry as Record<string, unknown>\n return candidate.recordId === recordId || candidate.id === recordId\n })\n if (match && typeof match === 'object') {\n const patch = (match as Record<string, unknown>).patch\n if (patch && typeof patch === 'object' && !Array.isArray(patch)) {\n return patch as Record<string, unknown>\n }\n const envelope = new Set(['recordId', 'id'])\n const reduced: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(match as Record<string, unknown>)) {\n if (envelope.has(key)) continue\n reduced[key] = value\n }\n return reduced\n }\n }\n return {}\n}\n\nfunction fallbackPatchDiffKeys(\n patch: Record<string, unknown>,\n before: Record<string, unknown>,\n): string[] {\n return Object.keys(patch ?? {}).filter((key) =>\n Object.prototype.hasOwnProperty.call(before ?? {}, key),\n )\n}\n\nfunction normalizeAttachmentIds(args: Record<string, unknown>): string[] {\n const raw = args?.attachmentIds\n if (!Array.isArray(raw)) return []\n return raw.filter((value): value is string => typeof value === 'string' && value.length > 0)\n}\n\nasync function buildSingleRecordDiff(\n tool: AiToolDefinition,\n input: PrepareMutationInput,\n ctx: PrepareMutationContext,\n): Promise<{\n fieldDiff: AiPendingActionFieldDiff[]\n targetEntityType: string | null\n targetRecordId: string | null\n recordVersion: string | null\n sideEffectsSummary: string | null\n}> {\n const resolver = tool.loadBeforeRecord\n if (!resolver) {\n logger.warn('prepareMutation: tool declared isMutation=true but no loadBeforeRecord resolver; shipping empty fieldDiff', { toolName: tool.name })\n return {\n fieldDiff: [],\n targetEntityType: null,\n targetRecordId: null,\n recordVersion: null,\n sideEffectsSummary: NO_RESOLVER_SIDE_EFFECTS_MESSAGE,\n }\n }\n const handlerContext = toolHandlerContext(ctx)\n const before: AiToolLoadBeforeSingleRecord | null = await resolver(\n input.toolCallArgs as never,\n handlerContext,\n )\n if (!before) {\n return {\n fieldDiff: [],\n targetEntityType: null,\n targetRecordId: null,\n recordVersion: null,\n sideEffectsSummary: null,\n }\n }\n const hasExplicitAfter = before.after != null\n const patch = before.after ?? extractPatchFromArgs(input.toolCallArgs)\n const fieldDiff = computeFieldDiff(\n before.before,\n patch,\n before.display,\n hasExplicitAfter ? undefined : { keys: fallbackPatchDiffKeys(patch, before.before) },\n )\n return {\n fieldDiff,\n targetEntityType: before.entityType,\n targetRecordId: before.recordId,\n recordVersion: before.recordVersion,\n sideEffectsSummary: null,\n }\n}\n\nasync function buildBatchRecords(\n tool: AiToolDefinition,\n input: PrepareMutationInput,\n ctx: PrepareMutationContext,\n): Promise<{\n records: AiPendingActionRecordDiff[] | null\n targetEntityType: string | null\n sideEffectsSummary: string | null\n}> {\n const resolver = tool.loadBeforeRecords\n if (!resolver) {\n logger.warn('prepareMutation: bulk tool declared isMutation=true but no loadBeforeRecords resolver; shipping empty records[]', { toolName: tool.name })\n return {\n records: null,\n targetEntityType: null,\n sideEffectsSummary: NO_RESOLVER_SIDE_EFFECTS_MESSAGE,\n }\n }\n const handlerContext = toolHandlerContext(ctx)\n const rows: AiToolLoadBeforeRecord[] = await resolver(\n input.toolCallArgs as never,\n handlerContext,\n )\n if (!Array.isArray(rows) || rows.length === 0) {\n return {\n records: null,\n targetEntityType: null,\n sideEffectsSummary: null,\n }\n }\n const diffs: AiPendingActionRecordDiff[] = rows.map((row) => {\n const hasExplicitAfter = row.after != null\n const patch = row.after ?? matchBatchPatch(input.toolCallArgs, row.recordId)\n return {\n recordId: row.recordId,\n entityType: row.entityType,\n label: row.label,\n fieldDiff: computeFieldDiff(\n row.before,\n patch,\n row.display,\n hasExplicitAfter ? undefined : { keys: fallbackPatchDiffKeys(patch, row.before) },\n ),\n recordVersion: row.recordVersion ?? null,\n }\n })\n const [firstEntity] = rows\n return {\n records: diffs,\n targetEntityType: firstEntity ? firstEntity.entityType : null,\n sideEffectsSummary: null,\n }\n}\n\n/**\n * Intercepts a mutation tool call and turns it into an `AiPendingAction` +\n * `mutation-preview-card` UI part (spec Phase 3 WS-C \u00A79). The caller MUST\n * have already confirmed the agent's effective `mutationPolicy` is NOT\n * `read-only`; this helper repeats the check defensively because skipping it\n * would be a policy-bypass.\n *\n * The tool handler is NEVER invoked by this function \u2014 the write is\n * short-circuited and only runs from the Step 5.8 confirm route. See the\n * unit test `does not call the tool handler` for the guard.\n */\nexport async function prepareMutation(\n input: PrepareMutationInput,\n ctx: PrepareMutationContext,\n): Promise<PrepareMutationResult> {\n const { agent, tool } = input\n if (tool.isMutation !== true) {\n throw new AiMutationPreparationError(\n 'not_a_mutation_tool',\n `Tool \"${tool.name}\" is not a mutation tool; prepareMutation should not be invoked.`,\n )\n }\n const effectivePolicy = resolveEffectiveMutationPolicy(\n agent.mutationPolicy,\n input.mutationPolicyOverride ?? null,\n agent.id,\n )\n if (effectivePolicy === 'read-only') {\n throw new AiMutationPreparationError(\n 'read_only_agent',\n `Agent \"${agent.id}\" has effective mutationPolicy=read-only; mutation tool \"${tool.name}\" cannot be prepared.`,\n )\n }\n\n const tenantId = assertTenantScope(ctx)\n const em = resolveEm(ctx.container)\n const repo = new AiPendingActionRepository(em)\n\n const isBulk = tool.isBulk === true\n let fieldDiff: AiPendingActionFieldDiff[] = []\n let records: AiPendingActionRecordDiff[] | null = null\n let targetEntityType: string | null = null\n let targetRecordId: string | null = null\n let recordVersion: string | null = null\n let sideEffectsSummary: string | null = null\n\n if (isBulk) {\n const batch = await buildBatchRecords(tool, input, ctx)\n records = batch.records\n targetEntityType = batch.targetEntityType\n sideEffectsSummary = batch.sideEffectsSummary\n } else {\n const single = await buildSingleRecordDiff(tool, input, ctx)\n fieldDiff = single.fieldDiff\n targetEntityType = single.targetEntityType\n targetRecordId = single.targetRecordId\n recordVersion = single.recordVersion\n sideEffectsSummary = single.sideEffectsSummary\n }\n\n const normalizedInput = input.toolCallArgs ?? {}\n const conversationId = input.conversationId ?? null\n const idempotencyKey = computeMutationIdempotencyKey({\n tenantId,\n organizationId: ctx.organizationId ?? null,\n agentId: agent.id,\n conversationId,\n toolName: tool.name,\n normalizedInput,\n })\n\n const { row: pendingAction, reused } = await repo.create(\n {\n agentId: agent.id,\n toolName: tool.name,\n idempotencyKey,\n createdByUserId: ctx.userId,\n normalizedInput,\n conversationId,\n targetEntityType,\n targetRecordId,\n fieldDiff,\n records,\n sideEffectsSummary,\n recordVersion,\n attachmentIds: normalizeAttachmentIds(normalizedInput),\n now: input.now,\n },\n {\n tenantId,\n organizationId: ctx.organizationId ?? null,\n userId: ctx.userId,\n },\n )\n\n const uiPart: AiUiPart = {\n componentId: MUTATION_PREVIEW_CARD_COMPONENT_ID,\n props: {\n pendingActionId: pendingAction.id,\n expiresAt: pendingAction.expiresAt.toISOString(),\n ...(records ? { records } : { fieldDiff }),\n ...(sideEffectsSummary ? { sideEffectsSummary } : {}),\n },\n }\n\n return { uiPart, pendingAction, reused }\n}\n\nexport const MUTATION_PREVIEW_CARD_COMPONENT = MUTATION_PREVIEW_CARD_COMPONENT_ID\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAY3B,SAAS,sCAAsC;AAC/C,SAAS,iCAAiC;AAO1C,MAAM,SAAS,aAAa,cAAc;AAUnC,MAAM,mCAAmC,MAAM;AAAA,EACpD,YACkB,MAMhB,SACA;AACA,UAAM,OAAO;AARG;AAShB,SAAK,OAAO;AAAA,EACd;AACF;AAkCA,MAAM,qCAAqC;AAE3C,MAAM,mCACJ;AAEF,SAAS,kBAAkB,KAAqC;AAC9D,MAAI,CAAC,IAAI,UAAU;AACjB,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,SAAO,IAAI;AACb;AAEA,SAAS,UAAU,WAA2C;AAC5D,MAAI,CAAC,WAAW;AACd,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,MAAI,KAA2B;AAC/B,MAAI;AACF,SAAK,UAAU,QAAuB,IAAI;AAAA,EAC5C,QAAQ;AACN,SAAK;AAAA,EACP;AACA,MAAI,CAAC,IAAI;AACP,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,mBAAmB,KAA6C;AACvE,SAAO;AAAA,IACL,UAAU,IAAI;AAAA,IACd,gBAAgB,IAAI;AAAA,IACpB,QAAQ,IAAI;AAAA,IACZ,WAAW,IAAI;AAAA,IACf,cAAc,IAAI;AAAA,IAClB,cAAc,IAAI;AAAA,EACpB;AACF;AAEA,SAAS,cAAc,OAAwB;AAC7C,QAAM,OAAO,oBAAI,QAAgB;AACjC,SAAO,KAAK,UAAU,OAAO,CAAC,MAAM,QAAQ;AAC1C,QAAI,OAAO,OAAO,QAAQ,UAAU;AAClC,UAAI,KAAK,IAAI,GAAa,EAAG,QAAO;AACpC,WAAK,IAAI,GAAa;AACtB,YAAM,UAAU,OAAO,QAAQ,GAA8B;AAC7D,cAAQ,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,MAAO,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CAAE;AACvD,aAAO,QAAQ,OAAgC,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM;AAC9D,YAAI,CAAC,IAAI;AACT,eAAO;AAAA,MACT,GAAG,CAAC,CAAC;AAAA,IACP;AACA,WAAO;AAAA,EACT,CAAC;AACH;AAYO,SAAS,8BAA8B,OAOnC;AACT,QAAM,YAAY,cAAc;AAAA,IAC9B,QAAQ,MAAM;AAAA,IACd,KAAK,MAAM,kBAAkB;AAAA,IAC7B,OAAO,MAAM;AAAA,IACb,cAAc,MAAM,kBAAkB;AAAA,IACtC,MAAM,MAAM;AAAA,IACZ,OAAO,MAAM,mBAAmB,CAAC;AAAA,EACnC,CAAC;AACD,SAAO,WAAW,QAAQ,EAAE,OAAO,SAAS,EAAE,OAAO,KAAK;AAC5D;AAEA,SAAS,iBACP,QACA,OACA,SACA,SAC4B;AAC5B,QAAM,OAAmC,CAAC;AAC1C,QAAM,OAAO,SAAS,OAClB,IAAI,IAAY,QAAQ,IAAI,IAC5B,oBAAI,IAAY;AAAA,IACd,GAAG,OAAO,KAAK,UAAU,CAAC,CAAC;AAAA,IAC3B,GAAG,OAAO,KAAK,SAAS,CAAC,CAAC;AAAA,EAC5B,CAAC;AACL,aAAW,SAAS,MAAM;AACxB,UAAM,cAAc,SAAS,OAAO,KAAK,IAAI;AAC7C,UAAM,aAAa,QAAQ,MAAM,KAAK,IAAI;AAC1C,QAAI,CAAC,OAAO,GAAG,aAAa,UAAU,KAAK,cAAc,WAAW,MAAM,cAAc,UAAU,GAAG;AACnG,YAAM,aAAa,SAAS,cAAc,KAAK;AAC/C,YAAM,gBAAgB,SAAS,SAAS,KAAK;AAC7C,YAAM,eAAe,SAAS,QAAQ,KAAK;AAC3C,WAAK,KAAK;AAAA,QACR;AAAA,QACA,GAAI,eAAe,SAAY,EAAE,WAAW,IAAI,CAAC;AAAA,QACjD,QAAQ;AAAA,QACR,OAAO;AAAA,QACP,GAAI,kBAAkB,SAAY,EAAE,cAAc,IAAI,CAAC;AAAA,QACvD,GAAI,iBAAiB,SAAY,EAAE,aAAa,IAAI,CAAC;AAAA,MACvD,CAAC;AAAA,IACH;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,qBACP,MACyB;AACzB,QAAM,MAAM,MAAM;AAClB,MAAI,OAAO,OAAO,QAAQ,YAAY,CAAC,MAAM,QAAQ,GAAG,GAAG;AACzD,WAAO;AAAA,EACT;AAIA,QAAM,WAAW,oBAAI,IAAI;AAAA,IACvB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC;AACD,QAAM,UAAmC,CAAC;AAC1C,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,QAAQ,CAAC,CAAC,GAAG;AACrD,QAAI,SAAS,IAAI,GAAG,EAAG;AACvB,YAAQ,GAAG,IAAI;AAAA,EACjB;AACA,SAAO;AACT;AAEA,SAAS,gBACP,MACA,UACyB;AACzB,QAAM,UAAU,MAAM;AACtB,MAAI,MAAM,QAAQ,OAAO,GAAG;AAC1B,UAAM,QAAQ,QAAQ,KAAK,CAAC,UAAU;AACpC,UAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO;AAChD,YAAM,YAAY;AAClB,aAAO,UAAU,aAAa,YAAY,UAAU,OAAO;AAAA,IAC7D,CAAC;AACD,QAAI,SAAS,OAAO,UAAU,UAAU;AACtC,YAAM,QAAS,MAAkC;AACjD,UAAI,SAAS,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK,GAAG;AAC/D,eAAO;AAAA,MACT;AACA,YAAM,WAAW,oBAAI,IAAI,CAAC,YAAY,IAAI,CAAC;AAC3C,YAAM,UAAmC,CAAC;AAC1C,iBAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAgC,GAAG;AAC3E,YAAI,SAAS,IAAI,GAAG,EAAG;AACvB,gBAAQ,GAAG,IAAI;AAAA,MACjB;AACA,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO,CAAC;AACV;AAEA,SAAS,sBACP,OACA,QACU;AACV,SAAO,OAAO,KAAK,SAAS,CAAC,CAAC,EAAE;AAAA,IAAO,CAAC,QACtC,OAAO,UAAU,eAAe,KAAK,UAAU,CAAC,GAAG,GAAG;AAAA,EACxD;AACF;AAEA,SAAS,uBAAuB,MAAyC;AACvE,QAAM,MAAM,MAAM;AAClB,MAAI,CAAC,MAAM,QAAQ,GAAG,EAAG,QAAO,CAAC;AACjC,SAAO,IAAI,OAAO,CAAC,UAA2B,OAAO,UAAU,YAAY,MAAM,SAAS,CAAC;AAC7F;AAEA,eAAe,sBACb,MACA,OACA,KAOC;AACD,QAAM,WAAW,KAAK;AACtB,MAAI,CAAC,UAAU;AACb,WAAO,KAAK,6GAA6G,EAAE,UAAU,KAAK,KAAK,CAAC;AAChJ,WAAO;AAAA,MACL,WAAW,CAAC;AAAA,MACZ,kBAAkB;AAAA,MAClB,gBAAgB;AAAA,MAChB,eAAe;AAAA,MACf,oBAAoB;AAAA,IACtB;AAAA,EACF;AACA,QAAM,iBAAiB,mBAAmB,GAAG;AAC7C,QAAM,SAA8C,MAAM;AAAA,IACxD,MAAM;AAAA,IACN;AAAA,EACF;AACA,MAAI,CAAC,QAAQ;AACX,WAAO;AAAA,MACL,WAAW,CAAC;AAAA,MACZ,kBAAkB;AAAA,MAClB,gBAAgB;AAAA,MAChB,eAAe;AAAA,MACf,oBAAoB;AAAA,IACtB;AAAA,EACF;AACA,QAAM,mBAAmB,OAAO,SAAS;AACzC,QAAM,QAAQ,OAAO,SAAS,qBAAqB,MAAM,YAAY;AACrE,QAAM,YAAY;AAAA,IAChB,OAAO;AAAA,IACP;AAAA,IACA,OAAO;AAAA,IACP,mBAAmB,SAAY,EAAE,MAAM,sBAAsB,OAAO,OAAO,MAAM,EAAE;AAAA,EACrF;AACA,SAAO;AAAA,IACL;AAAA,IACA,kBAAkB,OAAO;AAAA,IACzB,gBAAgB,OAAO;AAAA,IACvB,eAAe,OAAO;AAAA,IACtB,oBAAoB;AAAA,EACtB;AACF;AAEA,eAAe,kBACb,MACA,OACA,KAKC;AACD,QAAM,WAAW,KAAK;AACtB,MAAI,CAAC,UAAU;AACb,WAAO,KAAK,mHAAmH,EAAE,UAAU,KAAK,KAAK,CAAC;AACtJ,WAAO;AAAA,MACL,SAAS;AAAA,MACT,kBAAkB;AAAA,MAClB,oBAAoB;AAAA,IACtB;AAAA,EACF;AACA,QAAM,iBAAiB,mBAAmB,GAAG;AAC7C,QAAM,OAAiC,MAAM;AAAA,IAC3C,MAAM;AAAA,IACN;AAAA,EACF;AACA,MAAI,CAAC,MAAM,QAAQ,IAAI,KAAK,KAAK,WAAW,GAAG;AAC7C,WAAO;AAAA,MACL,SAAS;AAAA,MACT,kBAAkB;AAAA,MAClB,oBAAoB;AAAA,IACtB;AAAA,EACF;AACA,QAAM,QAAqC,KAAK,IAAI,CAAC,QAAQ;AAC3D,UAAM,mBAAmB,IAAI,SAAS;AACtC,UAAM,QAAQ,IAAI,SAAS,gBAAgB,MAAM,cAAc,IAAI,QAAQ;AAC3E,WAAO;AAAA,MACL,UAAU,IAAI;AAAA,MACd,YAAY,IAAI;AAAA,MAChB,OAAO,IAAI;AAAA,MACX,WAAW;AAAA,QACT,IAAI;AAAA,QACJ;AAAA,QACA,IAAI;AAAA,QACJ,mBAAmB,SAAY,EAAE,MAAM,sBAAsB,OAAO,IAAI,MAAM,EAAE;AAAA,MAClF;AAAA,MACA,eAAe,IAAI,iBAAiB;AAAA,IACtC;AAAA,EACF,CAAC;AACD,QAAM,CAAC,WAAW,IAAI;AACtB,SAAO;AAAA,IACL,SAAS;AAAA,IACT,kBAAkB,cAAc,YAAY,aAAa;AAAA,IACzD,oBAAoB;AAAA,EACtB;AACF;AAaA,eAAsB,gBACpB,OACA,KACgC;AAChC,QAAM,EAAE,OAAO,KAAK,IAAI;AACxB,MAAI,KAAK,eAAe,MAAM;AAC5B,UAAM,IAAI;AAAA,MACR;AAAA,MACA,SAAS,KAAK,IAAI;AAAA,IACpB;AAAA,EACF;AACA,QAAM,kBAAkB;AAAA,IACtB,MAAM;AAAA,IACN,MAAM,0BAA0B;AAAA,IAChC,MAAM;AAAA,EACR;AACA,MAAI,oBAAoB,aAAa;AACnC,UAAM,IAAI;AAAA,MACR;AAAA,MACA,UAAU,MAAM,EAAE,4DAA4D,KAAK,IAAI;AAAA,IACzF;AAAA,EACF;AAEA,QAAM,WAAW,kBAAkB,GAAG;AACtC,QAAM,KAAK,UAAU,IAAI,SAAS;AAClC,QAAM,OAAO,IAAI,0BAA0B,EAAE;AAE7C,QAAM,SAAS,KAAK,WAAW;AAC/B,MAAI,YAAwC,CAAC;AAC7C,MAAI,UAA8C;AAClD,MAAI,mBAAkC;AACtC,MAAI,iBAAgC;AACpC,MAAI,gBAA+B;AACnC,MAAI,qBAAoC;AAExC,MAAI,QAAQ;AACV,UAAM,QAAQ,MAAM,kBAAkB,MAAM,OAAO,GAAG;AACtD,cAAU,MAAM;AAChB,uBAAmB,MAAM;AACzB,yBAAqB,MAAM;AAAA,EAC7B,OAAO;AACL,UAAM,SAAS,MAAM,sBAAsB,MAAM,OAAO,GAAG;AAC3D,gBAAY,OAAO;AACnB,uBAAmB,OAAO;AAC1B,qBAAiB,OAAO;AACxB,oBAAgB,OAAO;AACvB,yBAAqB,OAAO;AAAA,EAC9B;AAEA,QAAM,kBAAkB,MAAM,gBAAgB,CAAC;AAC/C,QAAM,iBAAiB,MAAM,kBAAkB;AAC/C,QAAM,iBAAiB,8BAA8B;AAAA,IACnD;AAAA,IACA,gBAAgB,IAAI,kBAAkB;AAAA,IACtC,SAAS,MAAM;AAAA,IACf;AAAA,IACA,UAAU,KAAK;AAAA,IACf;AAAA,EACF,CAAC;AAED,QAAM,EAAE,KAAK,eAAe,OAAO,IAAI,MAAM,KAAK;AAAA,IAChD;AAAA,MACE,SAAS,MAAM;AAAA,MACf,UAAU,KAAK;AAAA,MACf;AAAA,MACA,iBAAiB,IAAI;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,eAAe,uBAAuB,eAAe;AAAA,MACrD,KAAK,MAAM;AAAA,IACb;AAAA,IACA;AAAA,MACE;AAAA,MACA,gBAAgB,IAAI,kBAAkB;AAAA,MACtC,QAAQ,IAAI;AAAA,IACd;AAAA,EACF;AAEA,QAAM,SAAmB;AAAA,IACvB,aAAa;AAAA,IACb,OAAO;AAAA,MACL,iBAAiB,cAAc;AAAA,MAC/B,WAAW,cAAc,UAAU,YAAY;AAAA,MAC/C,GAAI,UAAU,EAAE,QAAQ,IAAI,EAAE,UAAU;AAAA,MACxC,GAAI,qBAAqB,EAAE,mBAAmB,IAAI,CAAC;AAAA,IACrD;AAAA,EACF;AAEA,SAAO,EAAE,QAAQ,eAAe,OAAO;AACzC;AAEO,MAAM,kCAAkC;",
|
|
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.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7622.1.0584ba2a04",
|
|
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.10.1-develop.
|
|
104
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
103
|
+
"@open-mercato/shared": "0.10.1-develop.7622.1.0584ba2a04",
|
|
104
|
+
"@open-mercato/ui": "0.10.1-develop.7622.1.0584ba2a04",
|
|
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.10.1-develop.
|
|
111
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
112
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
110
|
+
"@open-mercato/cli": "0.10.1-develop.7622.1.0584ba2a04",
|
|
111
|
+
"@open-mercato/shared": "0.10.1-develop.7622.1.0584ba2a04",
|
|
112
|
+
"@open-mercato/ui": "0.10.1-develop.7622.1.0584ba2a04",
|
|
113
113
|
"@types/react": "^19.2.18",
|
|
114
114
|
"@types/react-dom": "^19.2.5",
|
|
115
115
|
"react": "19.2.8",
|
|
@@ -43,6 +43,16 @@ export interface AiPendingActionCreateInput {
|
|
|
43
43
|
now?: Date
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export interface AiPendingActionCreateResult {
|
|
47
|
+
row: AiPendingAction
|
|
48
|
+
/**
|
|
49
|
+
* True when `row` is an existing row returned as-is (a still-`pending`
|
|
50
|
+
* row, or a `confirmed`/`executing` row whose mutation already ran) —
|
|
51
|
+
* i.e. no new row was inserted.
|
|
52
|
+
*/
|
|
53
|
+
reused: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
export interface AiPendingActionSetStatusExtra {
|
|
47
57
|
resolvedByUserId?: string | null
|
|
48
58
|
executionResult?: AiPendingActionExecutionResult | null
|
|
@@ -76,7 +86,7 @@ export class AiPendingActionRepository {
|
|
|
76
86
|
async create(
|
|
77
87
|
input: AiPendingActionCreateInput,
|
|
78
88
|
ctx: AiPendingActionContext,
|
|
79
|
-
): Promise<
|
|
89
|
+
): Promise<AiPendingActionCreateResult> {
|
|
80
90
|
if (!ctx?.tenantId) {
|
|
81
91
|
throw new Error('AiPendingActionRepository.create requires tenantId')
|
|
82
92
|
}
|
|
@@ -124,24 +134,49 @@ export class AiPendingActionRepository {
|
|
|
124
134
|
},
|
|
125
135
|
)
|
|
126
136
|
if (existing && existing.status === 'pending') {
|
|
127
|
-
return existing
|
|
137
|
+
return { row: existing, reused: true }
|
|
138
|
+
}
|
|
139
|
+
// A `confirmed`/`executing` row represents a mutation that already
|
|
140
|
+
// ran (or is running). Inserting a fresh row with the same key would
|
|
141
|
+
// collide with the unique `(tenantId, organizationId,
|
|
142
|
+
// idempotencyKey)` index — this happens whenever the model repeats
|
|
143
|
+
// the same tool call with the same arguments after it already
|
|
144
|
+
// succeeded. Return the existing row instead of inserting; the
|
|
145
|
+
// caller (`prepareMutation`) uses `reused` to tell the model the
|
|
146
|
+
// operation already ran rather than re-queuing it.
|
|
147
|
+
//
|
|
148
|
+
// This only holds within the row's own dedupe/TTL window
|
|
149
|
+
// (`now < expiresAt`, per the spec's "within the TTL" dedupe
|
|
150
|
+
// promise). Outside it, treat the row as stale: a legitimate repeat
|
|
151
|
+
// of the same arguments later on would otherwise be refused forever,
|
|
152
|
+
// and a process that died mid-execution would leave every retry
|
|
153
|
+
// stuck reading a dead `executing` row.
|
|
154
|
+
if (
|
|
155
|
+
existing &&
|
|
156
|
+
(existing.status === 'confirmed' || existing.status === 'executing') &&
|
|
157
|
+
now.getTime() < existing.expiresAt.getTime()
|
|
158
|
+
) {
|
|
159
|
+
return { row: existing, reused: true }
|
|
128
160
|
}
|
|
129
|
-
//
|
|
130
|
-
// `
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// and the model retries the SAME tool with the SAME args
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
161
|
+
// A terminal (`failed`/`cancelled`/`expired`) row, or a stale
|
|
162
|
+
// `confirmed`/`executing` row past its dedupe window, would also
|
|
163
|
+
// collide on the same unique index when we insert a fresh one with
|
|
164
|
+
// the same key — which happens whenever the operator clicks "Fix
|
|
165
|
+
// with AI" and the model retries the SAME tool with the SAME args,
|
|
166
|
+
// or simply repeats the same arguments later. Re-key the stale row
|
|
167
|
+
// (instead of deleting it) so a retry can always proceed: the stale
|
|
168
|
+
// row's own card keeps resolving to its terminal/prior state via a
|
|
169
|
+
// GET by id, rather than 404ing.
|
|
138
170
|
if (
|
|
139
171
|
existing &&
|
|
140
172
|
(existing.status === 'failed' ||
|
|
141
173
|
existing.status === 'cancelled' ||
|
|
142
|
-
existing.status === 'expired'
|
|
174
|
+
existing.status === 'expired' ||
|
|
175
|
+
existing.status === 'confirmed' ||
|
|
176
|
+
existing.status === 'executing')
|
|
143
177
|
) {
|
|
144
|
-
|
|
178
|
+
existing.idempotencyKey = `${existing.idempotencyKey}:superseded:${existing.id}`
|
|
179
|
+
await tx.persist(existing).flush()
|
|
145
180
|
}
|
|
146
181
|
const row = tx.create(AiPendingAction, {
|
|
147
182
|
tenantId: ctx.tenantId,
|
|
@@ -171,7 +206,7 @@ export class AiPendingActionRepository {
|
|
|
171
206
|
resolvedByUserId: null,
|
|
172
207
|
} as unknown as AiPendingAction)
|
|
173
208
|
await tx.persist(row).flush()
|
|
174
|
-
return row
|
|
209
|
+
return { row, reused: false }
|
|
175
210
|
})
|
|
176
211
|
}
|
|
177
212
|
|
package/src/modules/ai_assistant/data/repositories/__tests__/AiPendingActionRepository.test.ts
CHANGED
|
@@ -178,11 +178,12 @@ describe('AiPendingActionRepository', () => {
|
|
|
178
178
|
const now = new Date('2026-04-18T12:00:00.000Z')
|
|
179
179
|
const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
|
|
180
180
|
|
|
181
|
-
const row = await repo.create(
|
|
181
|
+
const { row, reused } = await repo.create(
|
|
182
182
|
baseInput({ now, ttlSeconds: 900 }),
|
|
183
183
|
ctx,
|
|
184
184
|
)
|
|
185
185
|
|
|
186
|
+
expect(reused).toBe(false)
|
|
186
187
|
expect(row.status).toBe('pending')
|
|
187
188
|
expect(row.tenantId).toBe(tenantAlpha)
|
|
188
189
|
expect(row.attachmentIds).toEqual([])
|
|
@@ -198,8 +199,8 @@ describe('AiPendingActionRepository', () => {
|
|
|
198
199
|
const repo = new AiPendingActionRepository(em)
|
|
199
200
|
const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
|
|
200
201
|
|
|
201
|
-
const first = await repo.create(baseInput({ idempotencyKey: 'idem-42' }), ctx)
|
|
202
|
-
const second = await repo.create(
|
|
202
|
+
const { row: first } = await repo.create(baseInput({ idempotencyKey: 'idem-42' }), ctx)
|
|
203
|
+
const { row: second, reused } = await repo.create(
|
|
203
204
|
baseInput({
|
|
204
205
|
idempotencyKey: 'idem-42',
|
|
205
206
|
normalizedInput: { productId: 'p-1', patch: { name: 'different-call' } },
|
|
@@ -207,29 +208,159 @@ describe('AiPendingActionRepository', () => {
|
|
|
207
208
|
ctx,
|
|
208
209
|
)
|
|
209
210
|
|
|
211
|
+
expect(reused).toBe(true)
|
|
210
212
|
expect(second.id).toBe(first.id)
|
|
211
213
|
// the repo MUST NOT mutate the existing row from the second call
|
|
212
214
|
expect(second.normalizedInput).toEqual(first.normalizedInput)
|
|
213
215
|
expect(em.__store).toHaveLength(1)
|
|
214
216
|
})
|
|
215
217
|
|
|
216
|
-
it('after a terminal status, same idempotencyKey mints a NEW row (new id)', async () => {
|
|
218
|
+
it('after a terminal status, same idempotencyKey mints a NEW row (new id) and re-keys the stale one', async () => {
|
|
217
219
|
const em = mockEm()
|
|
218
220
|
const repo = new AiPendingActionRepository(em)
|
|
219
221
|
const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
|
|
220
222
|
|
|
221
|
-
const first = await repo.create(baseInput({ idempotencyKey: 'idem-9' }), ctx)
|
|
223
|
+
const { row: first } = await repo.create(baseInput({ idempotencyKey: 'idem-9' }), ctx)
|
|
222
224
|
await repo.setStatus(first.id, 'cancelled', ctx, { resolvedByUserId: 'u-1' })
|
|
223
225
|
|
|
224
|
-
const second = await repo.create(baseInput({ idempotencyKey: 'idem-9' }), ctx)
|
|
226
|
+
const { row: second, reused } = await repo.create(baseInput({ idempotencyKey: 'idem-9' }), ctx)
|
|
227
|
+
expect(reused).toBe(false)
|
|
225
228
|
expect(second.id).not.toBe(first.id)
|
|
226
229
|
expect(second.status).toBe('pending')
|
|
227
|
-
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
230
|
+
expect(second.idempotencyKey).toBe('idem-9')
|
|
231
|
+
// The repo re-keys stale terminal rows (cancelled/failed/expired) instead
|
|
232
|
+
// of deleting them — so the unique-key constraint stays satisfied for the
|
|
233
|
+
// "Fix with AI" retry flow, AND the stale row's own card keeps resolving
|
|
234
|
+
// to its terminal state via GET-by-id instead of 404ing (issue #7550).
|
|
235
|
+
expect(em.__store).toHaveLength(2)
|
|
236
|
+
const stale = em.__store.find((candidate) => candidate.id === first.id)
|
|
237
|
+
expect(stale).toBeDefined()
|
|
238
|
+
expect(stale!.status).toBe('cancelled')
|
|
239
|
+
expect(stale!.idempotencyKey).toBe(`idem-9:superseded:${first.id}`)
|
|
240
|
+
const fresh = em.__store.find((candidate) => candidate.id === second.id)
|
|
241
|
+
expect(fresh!.idempotencyKey).toBe('idem-9')
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('a confirmed row is returned as-is on retry — no second row, no unique-index collision', async () => {
|
|
245
|
+
const em = mockEm()
|
|
246
|
+
const repo = new AiPendingActionRepository(em)
|
|
247
|
+
const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
|
|
248
|
+
|
|
249
|
+
const { row: first } = await repo.create(baseInput({ idempotencyKey: 'idem-confirmed' }), ctx)
|
|
250
|
+
await repo.setStatus(first.id, 'confirmed', ctx, { resolvedByUserId: 'u-1' })
|
|
251
|
+
|
|
252
|
+
const { row: second, reused } = await repo.create(
|
|
253
|
+
baseInput({ idempotencyKey: 'idem-confirmed' }),
|
|
254
|
+
ctx,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
expect(reused).toBe(true)
|
|
258
|
+
expect(second.id).toBe(first.id)
|
|
259
|
+
expect(second.status).toBe('confirmed')
|
|
260
|
+
expect(em.__store).toHaveLength(1)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('an executing row is returned as-is on retry — no second row, no unique-index collision', async () => {
|
|
264
|
+
const em = mockEm()
|
|
265
|
+
const repo = new AiPendingActionRepository(em)
|
|
266
|
+
const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
|
|
267
|
+
|
|
268
|
+
const { row: first } = await repo.create(baseInput({ idempotencyKey: 'idem-executing' }), ctx)
|
|
269
|
+
await repo.setStatus(first.id, 'confirmed', ctx, { resolvedByUserId: 'u-1' })
|
|
270
|
+
await repo.setStatus(first.id, 'executing', ctx)
|
|
271
|
+
|
|
272
|
+
const { row: second, reused } = await repo.create(
|
|
273
|
+
baseInput({ idempotencyKey: 'idem-executing' }),
|
|
274
|
+
ctx,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
expect(reused).toBe(true)
|
|
278
|
+
expect(second.id).toBe(first.id)
|
|
279
|
+
expect(second.status).toBe('executing')
|
|
280
|
+
expect(em.__store).toHaveLength(1)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('a confirmed row is still reused just before its dedupe window elapses (now < expiresAt)', async () => {
|
|
284
|
+
const em = mockEm()
|
|
285
|
+
const repo = new AiPendingActionRepository(em)
|
|
286
|
+
const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
|
|
287
|
+
const createdAt = new Date('2026-04-18T12:00:00.000Z')
|
|
288
|
+
|
|
289
|
+
const { row: first } = await repo.create(
|
|
290
|
+
baseInput({ idempotencyKey: 'idem-window', now: createdAt, ttlSeconds: 900 }),
|
|
291
|
+
ctx,
|
|
292
|
+
)
|
|
293
|
+
await repo.setStatus(first.id, 'confirmed', ctx, { resolvedByUserId: 'u-1' })
|
|
294
|
+
|
|
295
|
+
const justBeforeExpiry = new Date(first.expiresAt.getTime() - 1)
|
|
296
|
+
const { row: second, reused } = await repo.create(
|
|
297
|
+
baseInput({ idempotencyKey: 'idem-window', now: justBeforeExpiry }),
|
|
298
|
+
ctx,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
expect(reused).toBe(true)
|
|
302
|
+
expect(second.id).toBe(first.id)
|
|
303
|
+
expect(second.status).toBe('confirmed')
|
|
231
304
|
expect(em.__store).toHaveLength(1)
|
|
232
|
-
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('a confirmed row past its dedupe window is re-keyed and a fresh pending row is minted', async () => {
|
|
308
|
+
const em = mockEm()
|
|
309
|
+
const repo = new AiPendingActionRepository(em)
|
|
310
|
+
const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
|
|
311
|
+
const createdAt = new Date('2026-04-18T12:00:00.000Z')
|
|
312
|
+
|
|
313
|
+
const { row: first } = await repo.create(
|
|
314
|
+
baseInput({ idempotencyKey: 'idem-stale-confirmed', now: createdAt, ttlSeconds: 900 }),
|
|
315
|
+
ctx,
|
|
316
|
+
)
|
|
317
|
+
await repo.setStatus(first.id, 'confirmed', ctx, { resolvedByUserId: 'u-1' })
|
|
318
|
+
|
|
319
|
+
const afterExpiry = new Date(first.expiresAt.getTime() + 1)
|
|
320
|
+
const { row: second, reused } = await repo.create(
|
|
321
|
+
baseInput({ idempotencyKey: 'idem-stale-confirmed', now: afterExpiry }),
|
|
322
|
+
ctx,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
expect(reused).toBe(false)
|
|
326
|
+
expect(second.id).not.toBe(first.id)
|
|
327
|
+
expect(second.status).toBe('pending')
|
|
328
|
+
expect(second.idempotencyKey).toBe('idem-stale-confirmed')
|
|
329
|
+
expect(em.__store).toHaveLength(2)
|
|
330
|
+
const stale = em.__store.find((candidate) => candidate.id === first.id)
|
|
331
|
+
expect(stale!.status).toBe('confirmed')
|
|
332
|
+
expect(stale!.idempotencyKey).toBe(`idem-stale-confirmed:superseded:${first.id}`)
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
it('an executing row past its dedupe window is re-keyed and a fresh pending row is minted', async () => {
|
|
336
|
+
const em = mockEm()
|
|
337
|
+
const repo = new AiPendingActionRepository(em)
|
|
338
|
+
const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
|
|
339
|
+
const createdAt = new Date('2026-04-18T12:00:00.000Z')
|
|
340
|
+
|
|
341
|
+
const { row: first } = await repo.create(
|
|
342
|
+
baseInput({ idempotencyKey: 'idem-stale-executing', now: createdAt, ttlSeconds: 900 }),
|
|
343
|
+
ctx,
|
|
344
|
+
)
|
|
345
|
+
await repo.setStatus(first.id, 'confirmed', ctx, { resolvedByUserId: 'u-1' })
|
|
346
|
+
await repo.setStatus(first.id, 'executing', ctx)
|
|
347
|
+
|
|
348
|
+
// a process dying mid-execution leaves this row stuck in `executing`
|
|
349
|
+
// forever — the dedupe window is what lets a later retry proceed
|
|
350
|
+
// instead of being told to "wait for the result" indefinitely.
|
|
351
|
+
const afterExpiry = new Date(first.expiresAt.getTime() + 1)
|
|
352
|
+
const { row: second, reused } = await repo.create(
|
|
353
|
+
baseInput({ idempotencyKey: 'idem-stale-executing', now: afterExpiry }),
|
|
354
|
+
ctx,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
expect(reused).toBe(false)
|
|
358
|
+
expect(second.id).not.toBe(first.id)
|
|
359
|
+
expect(second.status).toBe('pending')
|
|
360
|
+
expect(em.__store).toHaveLength(2)
|
|
361
|
+
const stale = em.__store.find((candidate) => candidate.id === first.id)
|
|
362
|
+
expect(stale!.status).toBe('executing')
|
|
363
|
+
expect(stale!.idempotencyKey).toBe(`idem-stale-executing:superseded:${first.id}`)
|
|
233
364
|
})
|
|
234
365
|
|
|
235
366
|
it('setStatus rejects illegal transitions (e.g. confirmed → pending) with AiPendingActionStateError', async () => {
|
|
@@ -237,7 +368,7 @@ describe('AiPendingActionRepository', () => {
|
|
|
237
368
|
const repo = new AiPendingActionRepository(em)
|
|
238
369
|
const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
|
|
239
370
|
|
|
240
|
-
const row = await repo.create(baseInput({ idempotencyKey: 'idem-illegal' }), ctx)
|
|
371
|
+
const { row } = await repo.create(baseInput({ idempotencyKey: 'idem-illegal' }), ctx)
|
|
241
372
|
await repo.setStatus(row.id, 'confirmed', ctx, { resolvedByUserId: 'u-1' })
|
|
242
373
|
|
|
243
374
|
await expect(
|
|
@@ -253,7 +384,7 @@ describe('AiPendingActionRepository', () => {
|
|
|
253
384
|
const repo = new AiPendingActionRepository(em)
|
|
254
385
|
const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
|
|
255
386
|
|
|
256
|
-
const row = await repo.create(baseInput({ idempotencyKey: 'idem-expire' }), ctx)
|
|
387
|
+
const { row } = await repo.create(baseInput({ idempotencyKey: 'idem-expire' }), ctx)
|
|
257
388
|
const expiredAt = new Date('2026-04-18T13:00:00.000Z')
|
|
258
389
|
const expired = await repo.setStatus(row.id, 'expired', ctx, { now: expiredAt })
|
|
259
390
|
|
|
@@ -317,7 +448,7 @@ describe('AiPendingActionRepository', () => {
|
|
|
317
448
|
const repo = new AiPendingActionRepository(em)
|
|
318
449
|
const ctxAlpha = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
|
|
319
450
|
|
|
320
|
-
const row = await repo.create(baseInput({ idempotencyKey: 'idem-iso' }), ctxAlpha)
|
|
451
|
+
const { row } = await repo.create(baseInput({ idempotencyKey: 'idem-iso' }), ctxAlpha)
|
|
321
452
|
const sameTenant = await repo.getById(row.id, ctxAlpha)
|
|
322
453
|
expect(sameTenant?.id).toBe(row.id)
|
|
323
454
|
|
|
@@ -336,7 +467,7 @@ describe('AiPendingActionRepository', () => {
|
|
|
336
467
|
const repo = new AiPendingActionRepository(em)
|
|
337
468
|
const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
|
|
338
469
|
|
|
339
|
-
const first = await repo.create(
|
|
470
|
+
const { row: first } = await repo.create(
|
|
340
471
|
baseInput({ idempotencyKey: 'p-1', agentId: 'catalog.assistant' }),
|
|
341
472
|
ctx,
|
|
342
473
|
)
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
prepareMutation,
|
|
36
36
|
} from '../prepare-mutation'
|
|
37
37
|
import { resolveAiAgentTools } from '../agent-tools'
|
|
38
|
+
import { AiPendingActionRepository } from '../../data/repositories/AiPendingActionRepository'
|
|
38
39
|
import type { AiAgentDefinition } from '../ai-agent-definition'
|
|
39
40
|
import type { AiToolDefinition } from '../types'
|
|
40
41
|
import {
|
|
@@ -717,6 +718,103 @@ describe('resolveAiAgentTools mutation interception (Step 5.6)', () => {
|
|
|
717
718
|
expect(String(outcome)).toMatch(/pending-confirmation/)
|
|
718
719
|
})
|
|
719
720
|
|
|
721
|
+
it('a reused confirmed row reports already-executed with executionResult, not pending-confirmation (issue #7550)', async () => {
|
|
722
|
+
const em = mockEm()
|
|
723
|
+
const container = makeContainer(em)
|
|
724
|
+
const tool: AiToolDefinition = {
|
|
725
|
+
name: 'catalog.products.update',
|
|
726
|
+
description: 'update',
|
|
727
|
+
inputSchema: z.object({}).passthrough(),
|
|
728
|
+
handler: async () => ({ shouldNeverRun: true }),
|
|
729
|
+
isMutation: true,
|
|
730
|
+
loadBeforeRecord: async () => ({
|
|
731
|
+
recordId: 'p-1',
|
|
732
|
+
entityType: 'catalog.product',
|
|
733
|
+
recordVersion: null,
|
|
734
|
+
before: { name: 'old' },
|
|
735
|
+
}),
|
|
736
|
+
}
|
|
737
|
+
registerMcpTool(tool, { moduleId: 'catalog' })
|
|
738
|
+
seedAgentRegistryForTests([
|
|
739
|
+
makeAgent({
|
|
740
|
+
id: 'catalog.merch',
|
|
741
|
+
allowedTools: ['catalog.products.update'],
|
|
742
|
+
}),
|
|
743
|
+
])
|
|
744
|
+
|
|
745
|
+
const resolved = await resolveAiAgentTools({
|
|
746
|
+
agentId: 'catalog.merch',
|
|
747
|
+
authContext: baseCtx,
|
|
748
|
+
container,
|
|
749
|
+
conversationId: 'conv-1',
|
|
750
|
+
})
|
|
751
|
+
const adapted = resolved.tools['catalog__products__update'] as unknown as {
|
|
752
|
+
execute: (args: unknown) => Promise<unknown>
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const firstOutcome = await adapted.execute({ productId: 'p-1', patch: { name: 'new' } })
|
|
756
|
+
expect(String(firstOutcome)).toMatch(/pending-confirmation/)
|
|
757
|
+
expect(em.__store).toHaveLength(1)
|
|
758
|
+
|
|
759
|
+
const repo = new AiPendingActionRepository(em)
|
|
760
|
+
const pendingActionId = em.__store[0].id
|
|
761
|
+
await repo.setStatus(pendingActionId, 'confirmed', baseCtx, { resolvedByUserId: 'u-1' })
|
|
762
|
+
em.__store[0].executionResult = { recordId: 'p-1' }
|
|
763
|
+
|
|
764
|
+
const secondOutcome = await adapted.execute({ productId: 'p-1', patch: { name: 'new' } })
|
|
765
|
+
// No second row was inserted — the unique-index collision from #7550 is avoided.
|
|
766
|
+
expect(em.__store).toHaveLength(1)
|
|
767
|
+
expect(String(secondOutcome)).toMatch(/already-executed/)
|
|
768
|
+
expect(String(secondOutcome)).not.toMatch(/pending-confirmation/)
|
|
769
|
+
expect(String(secondOutcome)).toMatch(/"recordId": "p-1"/)
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
it('a reused executing row reports status=executing, not pending-confirmation (issue #7550)', async () => {
|
|
773
|
+
const em = mockEm()
|
|
774
|
+
const container = makeContainer(em)
|
|
775
|
+
const tool: AiToolDefinition = {
|
|
776
|
+
name: 'catalog.products.update',
|
|
777
|
+
description: 'update',
|
|
778
|
+
inputSchema: z.object({}).passthrough(),
|
|
779
|
+
handler: async () => ({ shouldNeverRun: true }),
|
|
780
|
+
isMutation: true,
|
|
781
|
+
loadBeforeRecord: async () => ({
|
|
782
|
+
recordId: 'p-1',
|
|
783
|
+
entityType: 'catalog.product',
|
|
784
|
+
recordVersion: null,
|
|
785
|
+
before: { name: 'old' },
|
|
786
|
+
}),
|
|
787
|
+
}
|
|
788
|
+
registerMcpTool(tool, { moduleId: 'catalog' })
|
|
789
|
+
seedAgentRegistryForTests([
|
|
790
|
+
makeAgent({
|
|
791
|
+
id: 'catalog.merch',
|
|
792
|
+
allowedTools: ['catalog.products.update'],
|
|
793
|
+
}),
|
|
794
|
+
])
|
|
795
|
+
|
|
796
|
+
const resolved = await resolveAiAgentTools({
|
|
797
|
+
agentId: 'catalog.merch',
|
|
798
|
+
authContext: baseCtx,
|
|
799
|
+
container,
|
|
800
|
+
conversationId: 'conv-1',
|
|
801
|
+
})
|
|
802
|
+
const adapted = resolved.tools['catalog__products__update'] as unknown as {
|
|
803
|
+
execute: (args: unknown) => Promise<unknown>
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
await adapted.execute({ productId: 'p-1', patch: { name: 'new' } })
|
|
807
|
+
const repo = new AiPendingActionRepository(em)
|
|
808
|
+
const pendingActionId = em.__store[0].id
|
|
809
|
+
await repo.setStatus(pendingActionId, 'confirmed', baseCtx, { resolvedByUserId: 'u-1' })
|
|
810
|
+
await repo.setStatus(pendingActionId, 'executing', baseCtx)
|
|
811
|
+
|
|
812
|
+
const secondOutcome = await adapted.execute({ productId: 'p-1', patch: { name: 'new' } })
|
|
813
|
+
expect(em.__store).toHaveLength(1)
|
|
814
|
+
expect(String(secondOutcome)).toMatch(/"status": "executing"/)
|
|
815
|
+
expect(String(secondOutcome)).not.toMatch(/pending-confirmation/)
|
|
816
|
+
})
|
|
817
|
+
|
|
720
818
|
it('does NOT intercept non-mutation tools even when the agent is mutation-capable', async () => {
|
|
721
819
|
const em = mockEm()
|
|
722
820
|
const container = makeContainer(em)
|
|
@@ -6,6 +6,10 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
6
6
|
import type { AiAgentDefinition, AiAgentMutationPolicy } from './ai-agent-definition'
|
|
7
7
|
import type { AiChatRequestContext, AiUiPart } from './attachment-bridge-types'
|
|
8
8
|
import type { AiToolDefinition, McpToolContext } from './types'
|
|
9
|
+
import type {
|
|
10
|
+
AiPendingActionExecutionResult,
|
|
11
|
+
AiPendingActionStatus,
|
|
12
|
+
} from './pending-action-types'
|
|
9
13
|
import { loadAgentRegistry } from './agent-registry'
|
|
10
14
|
import {
|
|
11
15
|
checkAgentPolicy,
|
|
@@ -180,7 +184,28 @@ function formatPendingActionToolResult(
|
|
|
180
184
|
tool: AiToolDefinition,
|
|
181
185
|
pendingActionId: string,
|
|
182
186
|
expiresAt: Date,
|
|
187
|
+
reused: boolean,
|
|
188
|
+
status: AiPendingActionStatus,
|
|
189
|
+
executionResult: AiPendingActionExecutionResult | null,
|
|
183
190
|
): string {
|
|
191
|
+
// A reused `confirmed`/`executing` row means this exact mutation already
|
|
192
|
+
// ran (or is running) under the same idempotency key — tell the model so
|
|
193
|
+
// it does not resubmit, instead of always claiming a fresh approval is
|
|
194
|
+
// pending (issue #7550).
|
|
195
|
+
if (reused && (status === 'confirmed' || status === 'executing')) {
|
|
196
|
+
const resultStatus = status === 'executing' ? 'executing' : 'already-executed'
|
|
197
|
+
return formatToolResult({
|
|
198
|
+
status: resultStatus,
|
|
199
|
+
agentId: agent.id,
|
|
200
|
+
toolName: tool.name,
|
|
201
|
+
pendingActionId,
|
|
202
|
+
executionResult: executionResult ?? null,
|
|
203
|
+
message:
|
|
204
|
+
resultStatus === 'executing'
|
|
205
|
+
? `Mutation "${tool.name}" is already executing from a prior confirmed call. Do NOT submit it again; wait for the result.`
|
|
206
|
+
: `Mutation "${tool.name}" already ran for this input (idempotency key reused). Do NOT submit it again — see executionResult for the prior outcome.`,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
184
209
|
return formatToolResult({
|
|
185
210
|
status: 'pending-confirmation',
|
|
186
211
|
agentId: agent.id,
|
|
@@ -245,7 +270,7 @@ function adaptToolToAiSdk(
|
|
|
245
270
|
args && typeof args === 'object' && !Array.isArray(args)
|
|
246
271
|
? { ...(args as Record<string, unknown>) }
|
|
247
272
|
: {}
|
|
248
|
-
const { uiPart, pendingAction } = await prepareMutation(
|
|
273
|
+
const { uiPart, pendingAction, reused } = await prepareMutation(
|
|
249
274
|
{
|
|
250
275
|
agent: mutation.agent,
|
|
251
276
|
tool: mutation.tool,
|
|
@@ -268,6 +293,9 @@ function adaptToolToAiSdk(
|
|
|
268
293
|
mutation.tool,
|
|
269
294
|
pendingAction.id,
|
|
270
295
|
pendingAction.expiresAt,
|
|
296
|
+
reused,
|
|
297
|
+
pendingAction.status,
|
|
298
|
+
pendingAction.executionResult ?? null,
|
|
271
299
|
)
|
|
272
300
|
} catch (error) {
|
|
273
301
|
const message = error instanceof Error ? error.message : String(error)
|
|
@@ -68,6 +68,12 @@ export interface PrepareMutationContext extends AiChatRequestContext {
|
|
|
68
68
|
export interface PrepareMutationResult {
|
|
69
69
|
uiPart: AiUiPart
|
|
70
70
|
pendingAction: AiPendingAction
|
|
71
|
+
/**
|
|
72
|
+
* True when `pendingAction` is an existing row the repository returned
|
|
73
|
+
* as-is (still `pending`, or `confirmed`/`executing` because the
|
|
74
|
+
* mutation already ran) instead of a freshly inserted row.
|
|
75
|
+
*/
|
|
76
|
+
reused: boolean
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
const MUTATION_PREVIEW_CARD_COMPONENT_ID = 'mutation-preview-card'
|
|
@@ -443,7 +449,7 @@ export async function prepareMutation(
|
|
|
443
449
|
normalizedInput,
|
|
444
450
|
})
|
|
445
451
|
|
|
446
|
-
const pendingAction = await repo.create(
|
|
452
|
+
const { row: pendingAction, reused } = await repo.create(
|
|
447
453
|
{
|
|
448
454
|
agentId: agent.id,
|
|
449
455
|
toolName: tool.name,
|
|
@@ -477,7 +483,7 @@ export async function prepareMutation(
|
|
|
477
483
|
},
|
|
478
484
|
}
|
|
479
485
|
|
|
480
|
-
return { uiPart, pendingAction }
|
|
486
|
+
return { uiPart, pendingAction, reused }
|
|
481
487
|
}
|
|
482
488
|
|
|
483
489
|
export const MUTATION_PREVIEW_CARD_COMPONENT = MUTATION_PREVIEW_CARD_COMPONENT_ID
|