diff --git a/src/client/metadataApiDeploy.ts b/src/client/metadataApiDeploy.ts index 1e2bbb2bf..2b019d67f 100644 --- a/src/client/metadataApiDeploy.ts +++ b/src/client/metadataApiDeploy.ts @@ -15,6 +15,7 @@ */ import { join, relative, resolve as pathResolve, sep } from 'node:path'; import { format } from 'node:util'; +import { EOL } from 'node:os'; import { isString } from '@salesforce/ts-types'; import JSZip from 'jszip'; import fs from 'graceful-fs'; @@ -23,10 +24,10 @@ import { Messages } from '@salesforce/core/messages'; import { SfError } from '@salesforce/core/sfError'; import { envVars } from '@salesforce/core/envVars'; import { ensureArray } from '@salesforce/kit'; -import { RegistryAccess } from '../registry/registryAccess'; +import { RegistryAccess } from '../registry'; import { ReplacementEvent } from '../convert/types'; -import { MetadataConverter } from '../convert/metadataConverter'; -import { ComponentSet } from '../collections/componentSet'; +import { MetadataConverter } from '../convert'; +import { ComponentSet } from '../collections'; import { MetadataTransfer, MetadataTransferOptions } from './metadataTransfer'; import { AsyncResult, @@ -203,7 +204,111 @@ export class MetadataApiDeploy extends MetadataTransfer< // this is used as the version in the manifest (package.xml). this.components.sourceApiVersion ??= apiVersion; } + if (this.options.components) { + // we must ensure AiAuthoringBundles compile before deployment + // Use optimized getter method instead of filtering all components + const aabComponents = this.options.components.getAiAuthoringBundles().toArray(); + + if (aabComponents.length > 0) { + // we need to use a namedJWT connection for this request + const { accessToken, instanceUrl } = connection.getConnectionOptions(); + if (!instanceUrl) { + throw SfError.create({ + name: 'ApiAccessError', + message: 'Missing Instance URL for org connection', + }); + } + if (!accessToken) { + throw SfError.create({ + name: 'ApiAccessError', + message: 'Missing Access Token for org connection', + }); + } + const url = `${instanceUrl}/agentforce/bootstrap/nameduser`; + // For the namdeduser endpoint request to work we need to delete the access token + delete connection.accessToken; + const response = await connection.request<{ + access_token: string; + }>( + { + method: 'GET', + url, + headers: { + 'Content-Type': 'application/json', + Cookie: `sid=${accessToken}`, + }, + }, + { retry: { maxRetries: 3 } } + ); + connection.accessToken = response.access_token; + const results = await Promise.all( + aabComponents.map(async (aab) => { + // aab.content points to a directory, we need to find the .agent file and read it + if (!aab.content) { + throw new SfError( + messages.getMessage('error_expected_source_files', [aab.fullName, 'aiauthoringbundle']), + 'ExpectedSourceFilesError' + ); + } + + const contentPath = aab.tree.find('content', aab.name, aab.content); + + if (!contentPath) { + // if this didn't exist, they'll have deploy issues anyways, but we can check here for type reasons + throw new SfError(`No .agent file found in directory: ${aab.content}`, 'MissingAgentFileError'); + } + + const agentContent = await fs.promises.readFile(contentPath, 'utf-8'); + + // to avoid circular dependencies between libraries, just call the compile endpoint here + const result = await connection.request<{ + // minimal typings here, more is returned, just using what we need + status: 'failure' | 'success'; + errors: Array<{ + description: string; + lineStart: number; + colStart: number; + }>; + // name added here for post-processing convenience + name: string; + }>({ + method: 'POST', + // this will need to be api.salesforce once changes are in prod + url: 'https://test.api.salesforce.com/einstein/ai-agent/v1.1/authoring/scripts', + headers: { + 'x-client-name': 'afdx', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + assets: [ + { + type: 'AFScript', + name: 'AFScript', + content: agentContent, + }, + ], + afScriptVersion: '1.0.1', + }), + }); + result.name = aab.name; + return result; + }) + ); + const errors = results + .filter((result) => result.status === 'failure') + .map((result) => + result.errors.map((r) => `${result.name}.agent: ${r.description} ${r.lineStart}:${r.colStart}`).join(EOL) + ); + + if (errors.length > 0) { + throw SfError.create({ + message: `${EOL}${errors.join(EOL)}`, + name: 'AgentCompilationError', + }); + } + } + } // only do event hooks if source, (NOT a metadata format) deploy if (this.options.components) { await LifecycleInstance.emit('scopedPreDeploy', { diff --git a/src/collections/componentSet.ts b/src/collections/componentSet.ts index abb022c31..d3bb96b14 100644 --- a/src/collections/componentSet.ts +++ b/src/collections/componentSet.ts @@ -106,6 +106,9 @@ export class ComponentSet extends LazyCollection { // used to store components meant for a "constructive" (not destructive) manifest private manifestComponents = new DecodeableMap>(); + // optimization: track AiAuthoringBundles separately for faster access during compilation check + private aiAuthoringBundles = new Set(); + private destructiveChangesType = DestructiveChangesType.POST; public constructor(components: Iterable = [], registry = new RegistryAccess()) { @@ -527,6 +530,16 @@ export class ComponentSet extends LazyCollection { return new LazyCollection(iter).filter((c) => c instanceof SourceComponent) as LazyCollection; } + /** + * Get all AiAuthoringBundle components in the set. + * This is an optimized method that uses a cached Set of AAB components. + * + * @returns Collection of AiAuthoringBundle source components + */ + public getAiAuthoringBundles(): LazyCollection { + return new LazyCollection(this.aiAuthoringBundles); + } + public add(component: ComponentLike, deletionType?: DestructiveChangesType): void { const key = simpleKey(component); if (!this.components.has(key)) { @@ -556,6 +569,11 @@ export class ComponentSet extends LazyCollection { // we're working with SourceComponents now this.components.get(key)?.set(srcKey, component); + // track AiAuthoringBundles separately for fast access + if (component.type.id === 'aiauthoringbundle') { + this.aiAuthoringBundles.add(component); + } + // Build maps of destructive components and regular components as they are added // as an optimization when building manifests. if (deletionType) { diff --git a/test/client/metadataApiDeploy.test.ts b/test/client/metadataApiDeploy.test.ts index c8da492d7..dd05dbfd8 100644 --- a/test/client/metadataApiDeploy.test.ts +++ b/test/client/metadataApiDeploy.test.ts @@ -17,10 +17,11 @@ import { basename, join, sep } from 'node:path'; import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup'; import chai, { assert, expect } from 'chai'; import { AnyJson, ensureString, getString } from '@salesforce/ts-types'; -import { envVars, Lifecycle, Messages, PollingClient, StatusResult } from '@salesforce/core'; +import { Connection, envVars, Lifecycle, Messages, PollingClient, StatusResult } from '@salesforce/core'; import { Duration } from '@salesforce/kit'; import deepEqualInAnyOrder from 'deep-equal-in-any-order'; import * as sinon from 'sinon'; +import fs from 'graceful-fs'; import { ComponentSet, ComponentStatus, @@ -70,15 +71,14 @@ describe('MetadataApiDeploy', () => { describe('Lifecycle', () => { describe('start', () => { - it('should not convert zip, but read from fs'); - it('should not mdapiDir, but generate zip buffer from it'); - it('should convert to metadata format and create zip', async () => { const components = new ComponentSet([matchingContentFile.COMPONENT]); const { operation, convertStub } = await stubMetadataDeploy($$, testOrg, { components, }); + expect(components.getAiAuthoringBundles().toArray()).to.be.empty; + await operation.start(); expect(convertStub.calledWith(components, 'metadata', { type: 'zip' })).to.be.true; @@ -1297,4 +1297,226 @@ describe('MetadataApiDeploy', () => { expect(mdOpts.apiOptions).to.have.property('singlePackage', true); }); }); + + describe('AiAuthoringBundle compilation', () => { + const aabType = registry.types.aiauthoringbundle; + const aabName = 'TestAAB'; + const aabContentDir = join('path', 'to', 'aiAuthoringBundles', aabName); + const agentFileName = `${aabName}.agent`; + const agentContent = 'test agent script content'; + + const createAABComponent = (): SourceComponent => + SourceComponent.createVirtualComponent( + { + name: aabName, + type: aabType, + xml: join(aabContentDir, `${aabName}${META_XML_SUFFIX}`), + content: aabContentDir, + }, + [ + { + dirPath: join('path', 'to', 'aiAuthoringBundles'), + children: [aabName], + }, + { + dirPath: aabContentDir, + children: [agentFileName], + }, + ] + ); + const aabComponent = createAABComponent(); + const components = new ComponentSet([aabComponent]); + + it('should throw error with correct data when compilation fails', async () => { + $$.SANDBOX.stub(Connection.prototype, 'retrieveMaxApiVersion').resolves('65.0'); + const connection = await testOrg.getConnection(); + + const readFileStub = $$.SANDBOX.stub(fs.promises, 'readFile').resolves(agentContent); + + $$.SANDBOX.stub(connection, 'getConnectionOptions').returns({ + accessToken: 'test-access-token', + instanceUrl: 'https://test.salesforce.com', + }); + + const compileErrors = [ + { description: 'Syntax error on line 5', lineStart: 5, colStart: 10 }, + { description: 'Missing token', lineStart: 8, colStart: 15 }, + ]; + + // Configure connection.request stub (already created by TestContext) + let callCount = 0; + (connection.request as sinon.SinonStub).callsFake((request: { url?: string }) => { + callCount++; + if (request.url?.includes('agentforce/bootstrap/nameduser')) { + return Promise.resolve({ access_token: 'named-user-token' }); + } + if (request.url?.includes('einstein/ai-agent')) { + return Promise.resolve({ status: 'failure' as const, errors: compileErrors }); + } + // For other requests, return empty object (deploy stub handles its own requests) + return Promise.resolve({}); + }); + + const { operation } = await stubMetadataDeploy($$, testOrg, { components }); + + try { + await operation.start(); + expect.fail('Should have thrown AgentCompilationError'); + } catch (error: unknown) { + const err = error as { name?: string; message?: string }; + expect(err).to.have.property('name', 'AgentCompilationError'); + expect(err.message).to.include(`${aabName}.agent: Syntax error on line 5 5:10`); + expect(err.message).to.include(`${aabName}.agent: Missing token 8:15`); + } + + expect(readFileStub.calledOnce).to.be.true; + expect(callCount).to.be.at.least(2); + }); + + it('should not throw error when compilation succeeds', async () => { + const aabComponent = createAABComponent(); + const components = new ComponentSet([aabComponent]); + + // Stub retrieveMaxApiVersion on prototype before getting connection + $$.SANDBOX.stub(Connection.prototype, 'retrieveMaxApiVersion').resolves('60.0'); + const connection = await testOrg.getConnection(); + + const readFileStub = $$.SANDBOX.stub(fs.promises, 'readFile').resolves(agentContent); + + $$.SANDBOX.stub(connection, 'getConnectionOptions').returns({ + accessToken: 'test-access-token', + instanceUrl: 'https://test.salesforce.com', + }); + + // Configure connection.request stub (already created by TestContext) + let callCount = 0; + (connection.request as sinon.SinonStub).callsFake((request: { url?: string }) => { + callCount++; + if (request.url?.includes('agentforce/bootstrap/nameduser')) { + return Promise.resolve({ access_token: 'named-user-token' }); + } + if (request.url?.includes('einstein/ai-agent')) { + return Promise.resolve({ status: 'success' as const, errors: [] }); + } + // For other requests, return empty object (deploy stub handles its own requests) + return Promise.resolve({}); + }); + + const { operation } = await stubMetadataDeploy($$, testOrg, { components }); + + // Should not throw + await operation.start(); + + expect(readFileStub.calledOnce).to.be.true; + expect(callCount).to.be.at.least(2); + }); + + it('should not compile when no AABs present in component set', async () => { + const components = new ComponentSet([COMPONENT]); + + // Stub retrieveMaxApiVersion on prototype before getting connection + $$.SANDBOX.stub(Connection.prototype, 'retrieveMaxApiVersion').resolves('60.0'); + const connection = await testOrg.getConnection(); + + const readFileStub = $$.SANDBOX.stub(fs.promises, 'readFile'); + // Track calls to connection.request to verify compilation wasn't attempted + const compileCallCount = { count: 0 }; + (connection.request as sinon.SinonStub).callsFake((request: { url?: string }) => { + const url = request.url ?? ''; + if (url.includes('einstein/ai-agent') || url.includes('agentforce/bootstrap')) { + compileCallCount.count++; + } + // For other requests, return empty object (deploy stub handles its own requests) + return Promise.resolve({}); + }); + + const { operation } = await stubMetadataDeploy($$, testOrg, { components }); + + await operation.start(); + + // Verify compilation endpoints were not called + expect(readFileStub.called).to.be.false; + expect(compileCallCount.count).to.equal(0); + }); + + it('should handle multiple AABs in parallel', async () => { + const aab1 = SourceComponent.createVirtualComponent( + { + name: 'AAB1', + type: aabType, + xml: join('path', 'to', 'aiAuthoringBundles', 'AAB1', `AAB1${META_XML_SUFFIX}`), + content: join('path', 'to', 'aiAuthoringBundles', 'AAB1'), + }, + [ + { + dirPath: join('path', 'to', 'aiAuthoringBundles'), + children: ['AAB1'], + }, + { + dirPath: join('path', 'to', 'aiAuthoringBundles', 'AAB1'), + children: ['AAB1.agent'], + }, + ] + ); + + const aab2 = SourceComponent.createVirtualComponent( + { + name: 'AAB2', + type: aabType, + xml: join('path', 'to', 'aiAuthoringBundles', 'AAB2', `AAB2${META_XML_SUFFIX}`), + content: join('path', 'to', 'aiAuthoringBundles', 'AAB2'), + }, + [ + { + dirPath: join('path', 'to', 'aiAuthoringBundles'), + children: ['AAB2'], + }, + { + dirPath: join('path', 'to', 'aiAuthoringBundles', 'AAB2'), + children: ['AAB2.agent'], + }, + ] + ); + + const components = new ComponentSet([aab1, aab2]); + + // Stub retrieveMaxApiVersion on prototype before getting connection + $$.SANDBOX.stub(Connection.prototype, 'retrieveMaxApiVersion').resolves('60.0'); + const connection = await testOrg.getConnection(); + + const readFileStub = $$.SANDBOX.stub(fs.promises, 'readFile').resolves(agentContent); + + $$.SANDBOX.stub(connection, 'getConnectionOptions').returns({ + accessToken: 'test-access-token', + instanceUrl: 'https://test.salesforce.com', + }); + + // Configure connection.request stub (already created by TestContext) + // Handle multiple AABs: 2 nameduser + 2 compile calls + let namedUserCallCount = 0; + let compileCallCount = 0; + (connection.request as sinon.SinonStub).callsFake((request: { url?: string }) => { + if (request.url?.includes('agentforce/bootstrap/nameduser')) { + namedUserCallCount++; + return Promise.resolve({ access_token: 'named-user-token' }); + } + if (request.url?.includes('einstein/ai-agent')) { + compileCallCount++; + return Promise.resolve({ status: 'success' as const, errors: [] }); + } + // For other requests, return empty object (deploy stub handles its own requests) + return Promise.resolve({}); + }); + + const { operation } = await stubMetadataDeploy($$, testOrg, { components }); + + await operation.start(); + + // Should read both agent files + expect(readFileStub.callCount).to.equal(2); + // Should call compile endpoint twice (once per AAB) and nameduser once + expect(namedUserCallCount).to.equal(1); + expect(compileCallCount).to.equal(2); + }); + }); });