diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxModels.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxModels.ts index 84ab1e04a5..1ee872496f 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxModels.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxModels.ts @@ -151,6 +151,29 @@ export interface AtxGetTransformInfoResponse { // Surfaced so the IDE can restore the correct mode instead of relying on its local // settings store, which may be missing/stale (e.g. cold restart on another machine). InteractiveMode?: InteractiveMode + // ITGen (ATX_ITGEN_ENABLED only): every HITL open this poll, so a test-run HITL can't be + // hidden behind the one HitlTag/HitlTaskId picks. Absent when the poll didn't list HITLs. + OpenHitls?: AtxOpenHitl[] +} + +/** + * One open HITL task (contract v0 §3). Matches C# AtxOpenHitl class. + */ +export interface AtxOpenHitl { + TaskId: string + Tag?: string + // The plan step the task belongs to (for ITGen: the test project's step) + StepId?: string + ExpiryTimestampUTC?: string + // integ-app-launch / integ-test-run only: absolute path of the downloaded request.json + // (artifactWorkspace//hitl//request.json). Absent when the request is missing, + // can't be downloaded, or is rejected (RequestError says why). + RequestPath?: string + RequestError?: string + // integ-test-run only: absolute path of the extracted repo artifact holding the generated test + // projects (artifactWorkspace//itgen//tests); the request's tests.projectPath is + // relative to it. Downloaded again for every test-run task. + TestsPath?: string } /** @@ -279,6 +302,20 @@ export interface AtxCompleteLocalBuildHitlRequest extends ExecuteCommandParams { SolutionRootPath: string } +/** + * Completes an integ-test-run HITL (contract v0 §5: summary.json, plus trx//.trx + * or build-errors.json) or an integ-app-launch HITL (§6: launch-result.json, plus the startup + * log): uploads the result ZIP and submits the task by ID. + * ResultZipPath may be absolute or relative to SolutionRootPath. + */ +export interface AtxCompleteHitlWithTestResultsRequest extends ExecuteCommandParams { + WorkspaceId: string + TransformationJobId: string + TaskId: string + ResultZipPath: string + SolutionRootPath: string +} + export interface AtxCompleteLocalBuildHitlResponse { Success: boolean Error?: string @@ -346,16 +383,18 @@ export interface AtxLoadOlderWorklogsRequest extends ExecuteCommandParams { nextToken?: string } -// ITGen test-run HITL payloads (ITGen–IDE contract v0, schema 1.0). Draft: names follow the v0 +// ITGen test-run HITL payloads (ITGen–IDE contract v0, schema 1.1). Draft: names follow the v0 // proposal and match C# AtxTestRunRequest / AtxTestRunSummary / AtxAppLaunchRequest / // AtxAppLaunchResult in the VS extension. -export const ATX_TEST_RUN_SCHEMA_VERSION = '1.0' +// 1.1 (10/26) added launch-result launchedBy and dropped NOT_LOCALHOST; 1.0 payloads are still accepted +export const ATX_TEST_RUN_SCHEMA_VERSION = '1.1' export const ATX_TEST_RUN_BASE_URL_ENV = 'AB_BASE_URL' export type AtxTestRunSide = 'original' | 'transformed' -export type AtxTestRunStatus = 'COMPLETED' | 'TEST_BUILD_FAILED' | 'TIMED_OUT' | 'NOT_LOCALHOST' +// NOT_LOCALHOST was removed in 1.1: published URLs are allowed on the original side +export type AtxTestRunStatus = 'COMPLETED' | 'TEST_BUILD_FAILED' | 'TIMED_OUT' /** * Request from the Transform agent to run one test project (request.json of an integ-test-run HITL) @@ -368,8 +407,8 @@ export interface AtxTestRunRequest { schemaVersion: string // Names the TRX file (trx//.trx) and is echoed in the summary runId: string - // Also says where the app runs from: original = the copy uploaded at job start - // (artifactWorkspace//artifact/sourceCode), transformed = the customer's solution + // Also says where the app runs from: original = the copy of the original solution the IDE takes + // at job start (artifactWorkspace/before/), transformed = the customer's solution side: AtxTestRunSide project: string // The repo artifact the agent rewrites, holding the generated test projects: one per repo, @@ -398,6 +437,9 @@ export interface AtxAppLaunchApp { export type AtxAppLaunchStatus = 'STARTED' | 'APP_NOT_STARTED' +// Who started the app: the IDE, or the customer (who started it themselves or gave a URL) +export type AtxAppLaunchedBy = 'ide' | 'customer' + /** * Request from the Transform agent to start one web project (request.json of an integ-app-launch HITL), * one per launch attempt. Contract v0 defines only the result, so this shape is a proposal. Like LBV's @@ -422,6 +464,9 @@ export interface AtxAppLaunchResult { project: string attempt: number status: AtxAppLaunchStatus + // Since 1.1; absent means ide. For customer, appUrl is the URL the customer gave: localhost or + // published on the original side, localhost only on the transformed side + launchedBy?: AtxAppLaunchedBy appUrl?: string // The last health check; httpStatus is absent when the app didn't answer healthCheck?: { diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxNetTransformServer.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxNetTransformServer.ts index b74e6a8155..2fd8dcfe0f 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxNetTransformServer.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxNetTransformServer.ts @@ -21,6 +21,7 @@ import { AtxListArtifactsRequest, AtxDownloadArtifactRequest, AtxCompleteLocalBuildHitlRequest, + AtxCompleteHitlWithTestResultsRequest, AtxGetJobDashboardRequest, AtxGetJobReportRequest, AtxUploadCustomPlanRequest, @@ -43,6 +44,8 @@ const AtxBatchGetMessagesCommand = 'aws/atxTransform/batchGetMessages' const AtxListArtifactsCommand = 'aws/atxTransform/listArtifacts' const AtxDownloadArtifactCommand = 'aws/atxTransform/downloadArtifact' const AtxCompleteLocalBuildHitlCommand = 'aws/atxTransform/completeLocalBuildHitl' +// ITGen; the handler refuses it unless ATX_ITGEN_ENABLED is on +const AtxCompleteHitlWithTestResultsCommand = 'aws/atxTransform/completeHitlWithTestResults' const AtxGetJobDashboardCommand = 'aws/atxTransform/getJobDashboard' const AtxGetJobReportCommand = 'aws/atxTransform/getJobReport' const AtxUploadCustomPlanCommand = 'aws/atxTransform/uploadCustomPlan' @@ -237,6 +240,22 @@ export const AtxNetTransformServerToken = SolutionRootPath ) } + case AtxCompleteHitlWithTestResultsCommand: { + const { WorkspaceId, TransformationJobId, TaskId, ResultZipPath, SolutionRootPath } = + params as AtxCompleteHitlWithTestResultsRequest + + logging.log( + `ATX: completeHitlWithTestResults received — jobId=${TransformationJobId} taskId=${TaskId} zip=${ResultZipPath}` + ) + + return await atxTransformHandler.completeHitlWithTestResults( + WorkspaceId, + TransformationJobId, + TaskId, + ResultZipPath, + SolutionRootPath + ) + } case AtxGetJobDashboardCommand: { const { WorkspaceId, JobId } = params as AtxGetJobDashboardRequest @@ -343,6 +362,7 @@ export const AtxNetTransformServerToken = AtxListArtifactsCommand, AtxDownloadArtifactCommand, AtxCompleteLocalBuildHitlCommand, + AtxCompleteHitlWithTestResultsCommand, AtxGetJobDashboardCommand, AtxGetJobReportCommand, AtxUploadCustomPlanCommand, diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxTransformHandler.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxTransformHandler.ts index 60e5d1711e..4f06057520 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxTransformHandler.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/atxTransformHandler.ts @@ -63,6 +63,8 @@ import { AtxDashboardRepo, AtxUploadCustomPlanRequest, AtxUploadCustomPlanResponse, + AtxOpenHitl, + isSupportedAtxTestRunSchemaVersion, } from './atxModels' import { v4 as uuidv4 } from 'uuid' import { request } from 'http' @@ -75,6 +77,28 @@ import { BrowserInfo } from './models' export const ATX_OBJECTIVE_GENERATE_INTEGRATION_TESTS_KEY = 'generate_integration_tests' export const ATX_OBJECTIVE_BROWSER_KEY = 'browser' +// LS-side feature flag for the ITGen (integration test generation) plumbing. Off unless the env +// var is exactly 'true'; while off, the LS behaves exactly as before ITGen. +// TODO: row 18 replaces this with the real feature flag - change it here only. +export const ATX_ITGEN_ENABLED_ENV_VAR = 'ATX_ITGEN_ENABLED' + +export function isItGenEnabled(): boolean { + return process.env[ATX_ITGEN_ENABLED_ENV_VAR] === 'true' +} + +// ITGen HITL tags (contract v0 §3). The IDE answers both; one task per launch attempt / test run. +export const ATX_HITL_TAG_INTEG_APP_LAUNCH = 'integ-app-launch' +export const ATX_HITL_TAG_INTEG_TEST_RUN = 'integ-test-run' + +export function isItGenHitlTag(tag: unknown): boolean { + return tag === ATX_HITL_TAG_INTEG_APP_LAUNCH || tag === ATX_HITL_TAG_INTEG_TEST_RUN +} + +// Agent-supplied ids (task id, runId) become folder names; refuse anything that could leave the folder. +function isSafePathSegment(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) && !value.includes('..') +} + /** * Context object for tracking diff apply failures within a single getTransformInfo call. */ @@ -164,6 +188,10 @@ export class ATXTransformHandler { private interactiveModeByJob = new Map() private _applyingCheckpoints = false private _currentDiffContext: DiffApplyContext | null = null + // The last ListHitls result of the getTransformInfo call in progress (undefined = not listed + // yet), reused to build OpenHitls without a second ListHitls call. Same lifetime as + // _currentDiffContext. + private _currentPollHitls: any[] | undefined = undefined // sendMessage chat-poll cadence. Defaults give a 15-minute ceiling // (180 attempts x 5s). Held as fields rather than inline literals so a test can // drive the no-response path to completion instantly (interval 0, few attempts) @@ -1591,7 +1619,11 @@ export class ATXTransformHandler { const result = await this.atxClient!.send(command) this.logging.log(`ATX: ListHitls completed - Found ${result.hitlTasks?.length || 0} tasks`) - return result.hitlTasks || [] + const hitls = result.hitlTasks || [] + if (this._currentDiffContext && isItGenEnabled()) { + this._currentPollHitls = hitls + } + return hitls } catch (error) { this.logging.error(`ATX: ListHitls error: ${String(error)}`) return null @@ -1731,6 +1763,285 @@ export class ATXTransformHandler { } } + /** + * ITGen: completes an integ-test-run or integ-app-launch HITL with the IDE's result ZIP + * (contract v0 §5 / §6) — upload it as a HITL_FROM_USER ZIP, then submit the task by ID, like + * completeHitlWithBuildResults. The ZIP must hold a summary.json (test run) or a + * launch-result.json (app launch) with a supported schemaVersion; anything else is refused + * before upload so the agent never receives a result it can't read. On the transformed side the + * customer's changed files are added as workspace/ (addCustomerChangesToResultZip). No size + * limit here: the limit is an IDE constant (C13). + */ + async completeHitlWithTestResults( + workspaceId: string, + jobId: string, + taskId: string, + resultZipPath: string, + solutionRootPath: string + ): Promise<{ Success: boolean; Error?: string }> { + try { + if (!isItGenEnabled()) { + return { Success: false, Error: `completeHitlWithTestResults requires ${ATX_ITGEN_ENABLED_ENV_VAR}` } + } + if (!taskId) { + return { Success: false, Error: 'TaskId is required' } + } + + const zipPath = path.resolve(solutionRootPath || '', resultZipPath || '') + this.logging.log(`ATX: Completing HITL ${taskId} with test results ${zipPath}`) + if (!resultZipPath || !fs.existsSync(zipPath) || !fs.statSync(zipPath).isFile()) { + return { Success: false, Error: `Result ZIP not found: ${zipPath}` } + } + + const resultFile = this.readTestResultZip(zipPath) + if ('error' in resultFile) { + return { Success: false, Error: resultFile.error } + } + // The customer can change code at any time on the transformed side; the original copy + // is never sent back to the agent. + const uploadPath = + resultFile.result?.side === 'transformed' + ? this.addCustomerChangesToResultZip(zipPath, solutionRootPath, jobId) + : zipPath + + // HITL_FROM_USER for the same reason as completeHitlWithBuildResults: the submit + // fails with ResourceNotFoundException for any other category. + const uploadResponse = await this.createArtifactUploadUrl( + workspaceId, + jobId, + uploadPath, + CategoryType.HITL_FROM_USER, + FileType.ZIP + ) + if (!uploadResponse?.uploadUrl) { + return { Success: false, Error: 'Failed to create artifact upload URL' } + } + + const uploadSuccess = await Utils.uploadArtifact( + uploadResponse.uploadUrl, + uploadPath, + uploadResponse.requestHeaders, + this.logging + ) + if (!uploadSuccess) { + return { Success: false, Error: 'Failed to upload test results' } + } + + const completeResponse = await this.completeArtifactUpload(workspaceId, jobId, uploadResponse.uploadId) + if (!completeResponse?.success) { + return { Success: false, Error: 'Failed to complete artifact upload' } + } + + // integ-test-run is a CRITICAL HITL like LBV, so the critical submit closes it directly. + const submitResult = await this.submitHitl(workspaceId, jobId, taskId, uploadResponse.uploadId) + if (!submitResult) { + return { Success: false, Error: 'Failed to submit HITL task' } + } + + this.logging.log(`ATX: HITL ${taskId} completed with test results successfully`) + return { Success: true } + } catch (error) { + this.logging.error(`ATX: completeHitlWithTestResults error: ${String(error)}`) + return { Success: false, Error: String(error) } + } + } + + /** + * Reads an ITGen result ZIP's result file (summary.json for a test run, else launch-result.json + * for an app launch), or says why the ZIP can't be submitted. + */ + private readTestResultZip(zipPath: string): { result: any } | { error: string } { + let fileName: string + let result: any + try { + const zip = new AdmZip(zipPath) + const entry = zip.getEntry('summary.json') || zip.getEntry('launch-result.json') + if (!entry) { + return { error: 'Result ZIP has neither summary.json nor launch-result.json at its root' } + } + fileName = entry.entryName + result = JSON.parse(entry.getData().toString('utf-8')) + } catch (error) { + return { error: `Result ZIP is unreadable: ${String(error)}` } + } + if (!isSupportedAtxTestRunSchemaVersion(result?.schemaVersion)) { + return { error: `Unsupported ${fileName} schemaVersion: ${result?.schemaVersion}` } + } + return { result } + } + + /** + * ITGen (contract v0 §6): adds the customer's changed files to a transformed-side result ZIP as + * workspace/metadata.json ({ filesUpdated }) plus workspace/, with the same scan + * and metadata as updateWorkspace: every tracked file modified since job start. metadata.json is + * written even when nothing changed, so the agent can tell "no changes" from an old IDE. The + * IDE's ZIP is left as it is: the result goes to a copy next to it, whose path is returned. A + * ZIP that already has workspace/ is returned unchanged. + */ + private addCustomerChangesToResultZip(zipPath: string, solutionRootPath: string, jobId: string): string { + const zip = new AdmZip(zipPath) + if (zip.getEntries().some(entry => entry.entryName.startsWith('workspace/'))) { + return zipPath + } + + const filesUpdated: string[] = [] + for (const filePath of this.getModifiedFilesSinceCheckpoint(solutionRootPath, jobId, true)) { + const relativePath = path.relative(solutionRootPath, filePath) + if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + continue + } + try { + zip.addFile(`workspace/${relativePath.split(path.sep).join('/')}`, fs.readFileSync(filePath)) + filesUpdated.push(relativePath) + } catch (error) { + this.logging.error(`ATX: could not add ${filePath} to the result ZIP: ${String(error)}`) + } + } + zip.addFile('workspace/metadata.json', Buffer.from(JSON.stringify({ filesUpdated }, null, 2))) + + const uploadPath = zipPath.replace(/\.zip$/i, '') + '.with-workspace.zip' + zip.writeZip(uploadPath) + this.logging.log(`ATX: added ${filesUpdated.length} customer-changed file(s) to ${uploadPath}`) + return uploadPath + } + + /** + * ITGen: the first open integ-app-launch, else integ-test-run HITL in the pool. Undefined when + * ATX_ITGEN_ENABLED is off, so every selection site keeps today's order. + */ + private findItGenHitl(pool: any[]): any | undefined { + if (!isItGenEnabled()) { + return undefined + } + return ( + pool.find(h => h.tag === ATX_HITL_TAG_INTEG_APP_LAUNCH) || + pool.find(h => h.tag === ATX_HITL_TAG_INTEG_TEST_RUN) + ) + } + + /** + * ITGen: OpenHitls for the response (contract v0 §3), one per HITL listed this poll. For + * integ-* HITLs the agent's request is downloaded once per task to + * artifactWorkspace//hitl//request.json (a task's request never changes; each + * launch attempt or run is a new task) and its schemaVersion checked. + */ + private async buildOpenHitls( + workspaceId: string, + jobId: string, + solutionRootPath: string, + hitls: any[] + ): Promise { + const openHitls: AtxOpenHitl[] = [] + for (const hitl of hitls) { + if (!hitl?.taskId) { + continue + } + const openHitl: AtxOpenHitl = { TaskId: hitl.taskId } + if (hitl.tag) { + openHitl.Tag = hitl.tag + } + const stepId = this.getStepId(hitl) + if (stepId) { + openHitl.StepId = stepId + } + if (hitl.expiredAt) { + openHitl.ExpiryTimestampUTC = new Date(hitl.expiredAt).toISOString() + } + if (isItGenHitlTag(hitl.tag)) { + Object.assign(openHitl, await this.downloadItGenHitlRequest(workspaceId, jobId, solutionRootPath, hitl)) + } + openHitls.push(openHitl) + } + return openHitls + } + + private async downloadItGenHitlRequest( + workspaceId: string, + jobId: string, + solutionRootPath: string, + hitl: any + ): Promise> { + if (!isSafePathSegment(hitl.taskId)) { + return { RequestError: `Unsafe task id: ${hitl.taskId}` } + } + const requestPath = path.join(solutionRootPath, workspaceFolderName, jobId, 'hitl', hitl.taskId, 'request.json') + try { + let request: any + if (fs.existsSync(requestPath)) { + request = JSON.parse(fs.readFileSync(requestPath, 'utf-8')) + } else { + const artifactId = hitl.agentArtifact?.artifactId + if (!artifactId) { + return { RequestError: `${hitl.tag} HITL has no agent artifact` } + } + request = await this.downloadJsonArtifact(workspaceId, jobId, artifactId) + if (!request) { + return { RequestError: `Could not download the ${hitl.tag} request` } + } + await Utils.directoryExists(path.dirname(requestPath)) + fs.writeFileSync(requestPath, JSON.stringify(request, null, 2)) + this.logging.log(`ATX: ${hitl.tag} request for task ${hitl.taskId} saved to ${requestPath}`) + } + if (!isSupportedAtxTestRunSchemaVersion(request?.schemaVersion)) { + return { RequestError: `Unsupported ${hitl.tag} request schemaVersion: ${request?.schemaVersion}` } + } + if (hitl.tag !== ATX_HITL_TAG_INTEG_TEST_RUN) { + return { RequestPath: requestPath } + } + const tests = await this.downloadItGenTests(workspaceId, jobId, solutionRootPath, hitl.taskId, request) + return { RequestPath: requestPath, ...tests } + } catch (error) { + this.logging.error(`ATX: downloadItGenHitlRequest error for task ${hitl.taskId}: ${String(error)}`) + return { RequestError: String(error) } + } + } + + /** + * ITGen: downloads the repo artifact holding the generated test projects (request.artifactId) + * into artifactWorkspace//itgen//tests, for tests.projectPath to resolve against + * (contract v0 §4). The agent rewrites that artifact under the same id on every test fix, so + * it's downloaded again for every test-run task and never reused by id; a marker file holding + * the task id keeps one task's later polls from downloading it again. + */ + private async downloadItGenTests( + workspaceId: string, + jobId: string, + solutionRootPath: string, + taskId: string, + request: any + ): Promise> { + if (!request?.artifactId) { + return { RequestError: 'integ-test-run request has no artifactId' } + } + if (!isSafePathSegment(request.runId)) { + return { RequestError: `Unsafe integ-test-run runId: ${request.runId}` } + } + const runDir = path.join(solutionRootPath, workspaceFolderName, jobId, 'itgen', request.runId) + const testsPath = path.join(runDir, 'tests') + const markerPath = path.join(runDir, 'tests.task') + if (fs.existsSync(markerPath) && fs.readFileSync(markerPath, 'utf-8') === taskId && fs.existsSync(testsPath)) { + return { TestsPath: testsPath } + } + + const downloadInfo = await this.createArtifactDownloadUrl(workspaceId, jobId, request.artifactId) + if (!downloadInfo) { + return { RequestError: `Could not download the tests artifact ${request.artifactId}` } + } + fs.rmSync(testsPath, { recursive: true, force: true }) + const zipName = 'tests-artifact.zip' + await Utils.downloadAndExtractArchive( + downloadInfo.s3PresignedUrl, + downloadInfo.requestHeaders, + testsPath, + zipName, + this.logging + ) + fs.rmSync(path.join(testsPath, zipName), { force: true }) + fs.writeFileSync(markerPath, taskId) + this.logging.log(`ATX: tests artifact ${request.artifactId} for run ${request.runId} extracted to ${testsPath}`) + return { TestsPath: testsPath } + } + async getHitl(workspaceId: string, jobId: string, taskId: string): Promise { try { this.logging.log(`ATX: Getting Hitl task: ${taskId}`) @@ -1859,6 +2170,7 @@ export class ATXTransformHandler { scopedLbv || fallbackPool.find(h => h.tag === 'local-build-verification') || fallbackPool.find(h => h.tag === 'missing-packages' || h.tag === 'handle_missing_packages_hitl') || + this.findItGenHitl(fallbackPool) || fallbackPool[0] if (!hitl) { this.logging.log( @@ -1890,6 +2202,18 @@ export class ATXTransformHandler { : { HitlTag: hitlTag, TaskId: hitl.taskId } } + // ITGen: the IDE answers these too. The agent artifact is request.json, not a plan ZIP; + // it's downloaded per task for OpenHitls (buildOpenHitls), not here. + if (isItGenEnabled() && isItGenHitlTag(hitlTag)) { + const itGenStepId = this.getStepId(hitl) + this.logging.log( + `ATX: ${hitlTag} HITL — returning tag for IDE to handle | stepId=${itGenStepId ?? ''}` + ) + return itGenStepId !== undefined + ? { HitlTag: hitlTag, TaskId: hitl.taskId, StepId: itGenStepId } + : { HitlTag: hitlTag, TaskId: hitl.taskId } + } + // If no agent artifact, return tag and taskId without downloading if (!hitl.agentArtifact?.artifactId) { this.logging.log(`ATX: HITL has no agent artifact (tag=${hitlTag}) — returning tag only`) @@ -1987,9 +2311,20 @@ export class ATXTransformHandler { if (result && resolvedMode) { result.InteractiveMode = resolvedMode } + // ITGen: every open HITL, from the ListHitls this poll already made (terminal job + // statuses don't list HITLs, so they get no OpenHitls). + if (result && isItGenEnabled() && this._currentPollHitls !== undefined) { + result.OpenHitls = await this.buildOpenHitls( + request.WorkspaceId, + request.TransformationJobId, + request.SolutionRootPath, + this._currentPollHitls + ) + } return result } finally { this._currentDiffContext = null + this._currentPollHitls = undefined } } @@ -2191,6 +2526,7 @@ export class ATXTransformHandler { execFallbackPool.find( h => h.tag === 'missing-packages' || h.tag === 'handle_missing_packages_hitl' ) || + this.findItGenHitl(execFallbackPool) || execFallbackPool.find(h => String(h.tag).endsWith('-checkpoint')) || execFallbackPool[0] if (!execHitl) { @@ -2295,6 +2631,7 @@ export class ATXTransformHandler { planFallbackPool.find( h => h.tag === 'missing-packages' || h.tag === 'handle_missing_packages_hitl' ) || + this.findItGenHitl(planFallbackPool) || planFallbackPool[0] this.logging.log(`ATX: Found HITL task - tag: ${hitl.tag}, hasArtifact: ${!!hitl.agentArtifact}`) // For missing packages HITL, try to download artifact if available @@ -2342,6 +2679,23 @@ export class ATXTransformHandler { TransformationPlan: plan, } as AtxGetTransformInfoResponse } + if (isItGenEnabled() && isItGenHitlTag(hitl.tag)) { + const itGenStepId = this.getStepId(hitl) + this.logging.log( + `ATX: ${jobStatus} job has pending ${hitl.tag} HITL — taskId=${hitl.taskId} stepId=${itGenStepId ?? ''}; surfacing AWAITING_HUMAN_INPUT to IDE` + ) + return { + TransformationJob: { + WorkspaceId: request.WorkspaceId, + JobId: request.TransformationJobId, + Status: 'AWAITING_HUMAN_INPUT', + } as AtxTransformationJob, + HitlTag: hitl.tag, + HitlTaskId: hitl.taskId, + StepInformation: itGenStepId ? { StepId: itGenStepId } : undefined, + TransformationPlan: plan, + } as AtxGetTransformInfoResponse + } // -checkpoint surfaces only post-LBV; pre-job mode-selection stays filtered. if ( String(hitl.tag).endsWith('-checkpoint') && @@ -2441,6 +2795,27 @@ export class ATXTransformHandler { } as AtxGetTransformInfoResponse } + // ITGen app-launch / test-run HITLs: the IDE handles them, like LBV (the request is in OpenHitls). + if (isItGenEnabled() && isItGenHitlTag(hitlResponse?.HitlTag)) { + const plan = await this.getTransformationPlan( + request.WorkspaceId, + request.TransformationJobId, + request.SolutionRootPath, + request.IsBeam === true + ) + return { + TransformationJob: { + WorkspaceId: request.WorkspaceId, + JobId: request.TransformationJobId, + Status: 'AWAITING_HUMAN_INPUT', + } as any, + TransformationPlan: plan, + HitlTag: hitlResponse!.HitlTag, + HitlTaskId: hitlResponse!.TaskId, + StepInformation: hitlResponse!.StepId ? { StepId: hitlResponse!.StepId } : undefined, + } as AtxGetTransformInfoResponse + } + // Try to get the transformation plan first const plan = await this.getTransformationPlan( request.WorkspaceId, @@ -3647,10 +4022,16 @@ export class ATXTransformHandler { this._applyingCheckpoints = true try { - const completedSteps = this.findCompletedSteps(plan.Root) + let completedSteps: (AtxPlanStep & { score?: number })[] + if (isItGenEnabled()) { + // Already in application order (tree post-order). + completedSteps = await this.findCompletedStepsAnyDepth(workspaceId, jobId, solutionRootPath, plan.Root) + } else { + completedSteps = this.findCompletedSteps(plan.Root) - // Sort by score to ensure correct application order - completedSteps.sort((a, b) => (a.score || 0) - (b.score || 0)) + // Sort by score to ensure correct application order + completedSteps.sort((a, b) => (a.score || 0) - (b.score || 0)) + } this.logging.log(`ATX: Found ${completedSteps.length} completed steps to check for artifacts`) @@ -3807,6 +4188,101 @@ export class ATXTransformHandler { return completedSteps } + /** + * ITGen variant of findCompletedSteps: the not-yet-applied SUCCEEDED steps at ANY depth that + * have a diff to apply, i.e. a checkpoint folder on disk or a step-tagged CUSTOMER_OUTPUT + * artifact. With integration tests on, the agent nests LBV under local-verification, so the + * LBV error-fix rounds move to depth 3 and the nested LBV step itself (depth 2) has no diff. + * Keying on the artifact rather than the depth applies the rounds, and skips the LBV step + * instead of reporting it as a failed apply on every poll. A step whose artifact isn't + * listed yet is left out and picked up on a later poll. + * + * Returned in tree post-order (score-sorted siblings, children before their parent): a + * parent finishes after its children, and checkpoints overwrite whole files, so a parent's + * diff must land last. + */ + private async findCompletedStepsAnyDepth( + workspaceId: string, + jobId: string, + solutionRootPath: string, + root: AtxPlanStep + ): Promise { + const applied = new Set(this.loadAppliedCheckpoints(solutionRootPath, jobId)) + const pending = this.findSucceededStepsPostOrder(root).filter(step => !applied.has(step.StepId)) + const checkpointsDir = path.join(solutionRootPath, workspaceFolderName, jobId, 'checkpoints') + const isDownloaded = (step: AtxPlanStep) => fs.existsSync(path.join(checkpointsDir, step.StepId)) + + // Only list artifacts when some pending step still needs a download. + if (pending.every(isDownloaded)) { + return pending + } + + const stepIdsWithArtifact = await this.listStepIdsWithCustomerOutput(workspaceId, jobId) + if (!stepIdsWithArtifact) { + // Listing failed: apply what is already on disk and retry the rest on the next poll. + return pending.filter(isDownloaded) + } + return pending.filter(step => isDownloaded(step) || stepIdsWithArtifact.has(step.StepId)) + } + + /** + * All SUCCEEDED steps below the root, children before their parent. Siblings are already + * sorted by score (buildTreeFromFlatList). + */ + private findSucceededStepsPostOrder(step: AtxPlanStep, depth: number = 0): AtxPlanStep[] { + const steps: AtxPlanStep[] = [] + for (const child of step.Children) { + steps.push(...this.findSucceededStepsPostOrder(child, depth + 1)) + } + if (depth > 0 && step.Status === 'SUCCEEDED') { + steps.push(step) + } + return steps + } + + /** + * The plan step IDs that have at least one CUSTOMER_OUTPUT artifact for this job, from one + * paginated job-wide ListArtifacts. Job-level deliverables carry no planStepId and are + * ignored. Returns null on error. + */ + private async listStepIdsWithCustomerOutput(workspaceId: string, jobId: string): Promise | null> { + try { + if (!this.atxClient && !(await this.initializeAtxClient())) { + this.logging.error('ATX: Failed to initialize client for listStepIdsWithCustomerOutput') + return null + } + + const stepIds = new Set() + let nextToken: string | undefined + do { + const command = new ListArtifactsCommand({ + workspaceId: workspaceId, + jobFilter: { + jobId: jobId, + categoryType: 'CUSTOMER_OUTPUT', + }, + ...(nextToken && { nextToken }), + }) + + await this.addAuthToCommand(command) + const result = await this.atxClient!.send(command) + + for (const artifact of result?.artifacts || []) { + if (artifact.planStepId) { + stepIds.add(artifact.planStepId) + } + } + nextToken = result?.nextToken + } while (nextToken) + + this.logging.log(`ATX: ${stepIds.size} plan step(s) have CUSTOMER_OUTPUT artifacts for job: ${jobId}`) + return stepIds + } catch (error) { + this.logging.error(`ATX: listStepIdsWithCustomerOutput error: ${String(error)}`) + return null + } + } + /** * Downloads and extracts the artifact for a completed step. */ diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/atxTransformHandler.itgenHitl.test.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/atxTransformHandler.itgenHitl.test.ts new file mode 100644 index 0000000000..faa673a783 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/atxTransformHandler.itgenHitl.test.ts @@ -0,0 +1,544 @@ +import { expect } from 'chai' +import * as sinon from 'sinon' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import AdmZip = require('adm-zip') +import { CategoryType, FileType } from '@amazon/elastic-gumby-frontend-client' +import { + ATXTransformHandler, + ATX_HITL_TAG_INTEG_APP_LAUNCH, + ATX_HITL_TAG_INTEG_TEST_RUN, + ATX_ITGEN_ENABLED_ENV_VAR, +} from '../atxTransformHandler' +import { Utils, workspaceFolderName } from '../utils' +import { AtxTokenServiceManager } from '../../../shared/amazonQServiceManager/AtxTokenServiceManager' +import { Logging, Runtime, Workspace } from '@aws/language-server-runtimes/server-interface' + +const JOB_ID = 'job-itgen' +const WS_ID = 'ws-itgen' + +const testRunRequest = (overrides: any = {}) => ({ + schemaVersion: '1.0', + runId: 'r2', + side: 'original', + project: 'SampleWebApi', + artifactId: 'repo-artifact', + tests: { projectPath: 'GeneratedApiTests.SampleWebApi/GeneratedApiTests.SampleWebApi.csproj' }, + limits: { projectTimeoutSeconds: 1800 }, + ...overrides, +}) + +const testRunHitl = { taskId: 'task-run', tag: ATX_HITL_TAG_INTEG_TEST_RUN, planStepId: 'step-run' } +const appLaunchHitl = { taskId: 'task-launch', tag: ATX_HITL_TAG_INTEG_APP_LAUNCH, planStepId: 'step-launch' } + +describe('ATXTransformHandler - ITGen test HITLs', () => { + let handler: ATXTransformHandler + let logging: Logging + let tmpRoot: string + let hitls: any[] + let jobStatus: string + let downloadJsonStub: sinon.SinonStub + let downloadUrlStub: sinon.SinonStub + let zipDownloadStub: sinon.SinonStub + let requestsByArtifact: Record + + const poll = () => + handler.getTransformInfo({ + TransformationJobId: JOB_ID, + WorkspaceId: WS_ID, + SolutionRootPath: tmpRoot, + } as any) + + beforeEach(() => { + process.env[ATX_ITGEN_ENABLED_ENV_VAR] = 'true' + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'atx-itgen-hitl-')) + logging = { log: sinon.stub(), error: sinon.stub(), info: sinon.stub(), warn: sinon.stub() } as any + handler = new ATXTransformHandler( + sinon.createStubInstance(AtxTokenServiceManager) as any, + {} as Workspace, + logging, + {} as Runtime + ) + sinon.stub(handler as any, 'initializeAtxClient').resolves(true) + sinon.stub(handler as any, 'addAuthToCommand').resolves() + // Skip the interactive-mode lookups, which make their own ListHitlTasks calls. + ;(handler as any).interactiveModeByJob.set(JOB_ID, { mode: 'Interactive', source: 'test' }) + + hitls = [] + jobStatus = 'EXECUTING' + // The real listHitls runs against this fake, so OpenHitls sees exactly what selection saw. + ;(handler as any).atxClient = { + send: sinon.stub().callsFake(async (command: any) => { + if (command.constructor.name === 'ListHitlTasksCommand') { + return { hitlTasks: hitls } + } + throw new Error(`unexpected ${command.constructor.name}`) + }), + } + sinon.stub(handler, 'getJob').callsFake(async () => ({ statusDetails: { status: jobStatus } }) as any) + sinon.stub(handler, 'getTransformationPlan').resolves({ Root: { Children: [] } } as any) + sinon.stub(handler as any, 'listWorklogs').resolves(undefined) + + requestsByArtifact = { + 'art-run': testRunRequest(), + 'art-launch': { schemaVersion: '1.0', side: 'transformed' }, + } + downloadJsonStub = sinon + .stub(handler as any, 'downloadJsonArtifact') + .callsFake(async (...args: any[]) => requestsByArtifact[args[2]] ?? null) + // The tests artifact: "extract" one test project file plus the ZIP the real code leaves behind. + downloadUrlStub = sinon + .stub(handler, 'createArtifactDownloadUrl') + .callsFake(async (...args: any[]) => ({ s3PresignedUrl: `url-${args[2]}`, requestHeaders: {} })) + zipDownloadStub = sinon + .stub(Utils, 'downloadAndExtractArchive') + .callsFake(async (_url: string, _headers: any, saveToDir: string, exportName: string) => { + fs.mkdirSync(path.join(saveToDir, 'GeneratedApiTests.SampleWebApi'), { recursive: true }) + fs.writeFileSync(path.join(saveToDir, 'GeneratedApiTests.SampleWebApi', 'Tests.csproj'), '') + fs.writeFileSync(path.join(saveToDir, exportName), 'zip') + return saveToDir + }) + }) + + afterEach(() => { + sinon.restore() + delete process.env[ATX_ITGEN_ENABLED_ENV_VAR] + fs.rmSync(tmpRoot, { recursive: true, force: true }) + }) + + describe('HITL selection order', () => { + it('picks integ-app-launch before integ-test-run', async () => { + hitls = [testRunHitl, appLaunchHitl] + + const result = await poll() + + expect(result?.TransformationJob.Status).to.equal('AWAITING_HUMAN_INPUT') + expect(result?.HitlTag).to.equal(ATX_HITL_TAG_INTEG_APP_LAUNCH) + expect(result?.HitlTaskId).to.equal('task-launch') + expect(result?.StepInformation?.StepId).to.equal('step-launch') + }) + + it('keeps LBV and missing-packages ahead of the ITGen tags', async () => { + hitls = [testRunHitl, { taskId: 'task-lbv', tag: 'local-build-verification' }] + expect((await poll())?.HitlTag).to.equal('local-build-verification') + + hitls = [testRunHitl, { taskId: 'task-pkg', tag: 'missing-packages' }] + expect((await poll())?.HitlTag).to.equal('missing-packages') + }) + + it('puts integ-test-run ahead of a post-LBV checkpoint only when the flag is on', async () => { + ;(handler as any).jobsPastLocalBuild.add(JOB_ID) + hitls = [{ taskId: 'task-cp', tag: `${JOB_ID}-checkpoint` }, testRunHitl] + + expect((await poll())?.HitlTag).to.equal(ATX_HITL_TAG_INTEG_TEST_RUN) + + delete process.env[ATX_ITGEN_ENABLED_ENV_VAR] + expect((await poll())?.HitlTag).to.equal(`${JOB_ID}-checkpoint`) + }) + + it('surfaces an ITGen HITL with its step on a PLANNING job, not as an unhandled tag', async () => { + jobStatus = 'PLANNING' + hitls = [testRunHitl] + + const result = await poll() + + expect(result?.TransformationJob.Status).to.equal('AWAITING_HUMAN_INPUT') + expect(result?.HitlTag).to.equal(ATX_HITL_TAG_INTEG_TEST_RUN) + expect(result?.StepInformation?.StepId).to.equal('step-run') + expect((logging.warn as sinon.SinonStub).called).to.be.false + }) + + it('returns an ITGen HITL directly on an AWAITING_HUMAN_INPUT job, without a plan-ZIP download', async () => { + jobStatus = 'AWAITING_HUMAN_INPUT' + hitls = [{ ...testRunHitl, agentArtifact: { artifactId: 'art-run' } }] + + const result = await poll() + + expect(result?.HitlTag).to.equal(ATX_HITL_TAG_INTEG_TEST_RUN) + expect(result?.HitlTaskId).to.equal('task-run') + expect(result?.StepInformation?.StepId).to.equal('step-run') + // Only the tests artifact is extracted, never the request as a plan ZIP. + const zipNames = zipDownloadStub.getCalls().map(c => c.args[3]) + expect(zipNames).to.not.include('transformation-plan-download.zip') + }) + }) + + // With the flag off an integ-* tag is just an unknown tag: every path must behave exactly as it + // did before ITGen, and nothing ITGen-specific (request.json, tests download) may happen. + describe('flag off: existing behavior unchanged', () => { + beforeEach(() => { + delete process.env[ATX_ITGEN_ENABLED_ENV_VAR] + }) + + const noItGenFiles = () => { + expect(fs.existsSync(path.join(tmpRoot, workspaceFolderName, JOB_ID, 'hitl'))).to.be.false + expect(fs.existsSync(path.join(tmpRoot, workspaceFolderName, JOB_ID, 'itgen'))).to.be.false + expect(downloadJsonStub.called).to.be.false + } + + it('EXECUTING: an integ-* HITL is surfaced by the generic first-in-list rule, without OpenHitls', async () => { + hitls = [{ ...testRunHitl, agentArtifact: { artifactId: 'art-run' } }] + + const result = await poll() + + expect(result?.HitlTag).to.equal(ATX_HITL_TAG_INTEG_TEST_RUN) + expect(result?.OpenHitls).to.be.undefined + expect(zipDownloadStub.called).to.be.false + noItGenFiles() + }) + + it('PLANNING: an integ-* HITL goes down the defensive unhandled-tag path, as before', async () => { + jobStatus = 'PLANNING' + hitls = [testRunHitl] + + const result = await poll() + + expect(result?.HitlTag).to.equal(ATX_HITL_TAG_INTEG_TEST_RUN) + expect(result?.StepInformation).to.be.undefined + expect((logging.warn as sinon.SinonStub).calledWithMatch(/unhandled HITL tag/)).to.be.true + noItGenFiles() + }) + + it('AWAITING_HUMAN_INPUT: an integ-* HITL still takes the old agent-artifact ZIP path', async () => { + jobStatus = 'AWAITING_HUMAN_INPUT' + hitls = [{ ...testRunHitl, agentArtifact: { artifactId: 'art-run' } }] + + const result = await poll() + + expect(result?.HitlTag).to.equal(ATX_HITL_TAG_INTEG_TEST_RUN) + expect(result?.OpenHitls).to.be.undefined + const zipNames = zipDownloadStub.getCalls().map(c => c.args[3]) + expect(zipNames).to.include('transformation-plan-download.zip') + expect(zipNames).to.not.include('tests-artifact.zip') + noItGenFiles() + }) + }) + + describe('OpenHitls', () => { + it('lists every open HITL, with the downloaded request for ITGen ones', async () => { + const expiry = new Date('2026-10-20T19:30:00Z') + hitls = [ + { ...testRunHitl, agentArtifact: { artifactId: 'art-run' }, expiredAt: expiry }, + { taskId: 'task-cp', tag: `${JOB_ID}-checkpoint` }, + ] + + const result = await poll() + + const expectedPath = path.join(tmpRoot, workspaceFolderName, JOB_ID, 'hitl', 'task-run', 'request.json') + const expectedTests = path.join(tmpRoot, workspaceFolderName, JOB_ID, 'itgen', 'r2', 'tests') + expect(result?.OpenHitls).to.deep.equal([ + { + TaskId: 'task-run', + Tag: ATX_HITL_TAG_INTEG_TEST_RUN, + StepId: 'step-run', + ExpiryTimestampUTC: '2026-10-20T19:30:00.000Z', + RequestPath: expectedPath, + TestsPath: expectedTests, + }, + { TaskId: 'task-cp', Tag: `${JOB_ID}-checkpoint` }, + ]) + expect(JSON.parse(fs.readFileSync(expectedPath, 'utf-8'))).to.deep.equal(testRunRequest()) + // The repo artifact named in the request, extracted with its ZIP removed. + expect(downloadUrlStub.firstCall.args[2]).to.equal('repo-artifact') + expect(fs.existsSync(path.join(expectedTests, 'GeneratedApiTests.SampleWebApi', 'Tests.csproj'))).to.be.true + expect(fs.existsSync(path.join(expectedTests, 'tests-artifact.zip'))).to.be.false + }) + + it('downloads a task request and its tests once and reuses ListHitls (one call per poll)', async () => { + hitls = [{ ...testRunHitl, agentArtifact: { artifactId: 'art-run' } }] + + await poll() + await poll() + + expect(downloadJsonStub.callCount).to.equal(1) + expect(zipDownloadStub.callCount).to.equal(1) + const send = (handler as any).atxClient.send as sinon.SinonStub + expect(send.callCount).to.equal(2) + }) + + it('downloads the tests again for a new test-run task, since the artifact is rewritten under the same id', async () => { + hitls = [{ ...testRunHitl, agentArtifact: { artifactId: 'art-run' } }] + await poll() + + // The agent fixed the tests and opened a new run with a new task (same artifact id). + requestsByArtifact['art-run-2'] = testRunRequest({ runId: 'r3' }) + hitls = [{ ...testRunHitl, taskId: 'task-run-2', agentArtifact: { artifactId: 'art-run-2' } }] + const [open] = (await poll())!.OpenHitls! + + expect(zipDownloadStub.callCount).to.equal(2) + expect(downloadUrlStub.secondCall.args[2]).to.equal('repo-artifact') + expect(open.TestsPath).to.equal(path.join(tmpRoot, workspaceFolderName, JOB_ID, 'itgen', 'r3', 'tests')) + }) + + it('does not download tests for an app launch', async () => { + hitls = [{ ...appLaunchHitl, agentArtifact: { artifactId: 'art-launch' } }] + + const [open] = (await poll())!.OpenHitls! + + expect(open.RequestPath).to.not.be.undefined + expect(open.TestsPath).to.be.undefined + expect(zipDownloadStub.called).to.be.false + }) + + it('refuses a runId or task id that could leave the job folder', async () => { + requestsByArtifact['art-run'] = testRunRequest({ runId: '..\\..\\evil' }) + hitls = [ + { ...testRunHitl, agentArtifact: { artifactId: 'art-run' } }, + { ...appLaunchHitl, taskId: '../task', agentArtifact: { artifactId: 'art-launch' } }, + ] + + const [run, launch] = (await poll())!.OpenHitls! + + expect(run.TestsPath).to.be.undefined + expect(run.RequestError).to.contain('Unsafe integ-test-run runId') + expect(launch.RequestPath).to.be.undefined + expect(launch.RequestError).to.contain('Unsafe task id') + expect(zipDownloadStub.called).to.be.false + }) + + it('reports a tests artifact that cannot be downloaded', async () => { + downloadUrlStub.resolves(null) + hitls = [{ ...testRunHitl, agentArtifact: { artifactId: 'art-run' } }] + + const [open] = (await poll())!.OpenHitls! + + expect(open.RequestPath).to.not.be.undefined + expect(open.TestsPath).to.be.undefined + expect(open.RequestError).to.contain('Could not download the tests artifact repo-artifact') + }) + + it('rejects a request whose schemaVersion major is not 1', async () => { + requestsByArtifact['art-run'] = testRunRequest({ schemaVersion: '2.0' }) + hitls = [{ ...testRunHitl, agentArtifact: { artifactId: 'art-run' } }] + + const [open] = (await poll())!.OpenHitls! + + expect(open.RequestPath).to.be.undefined + expect(open.RequestError).to.contain('schemaVersion: 2.0') + }) + + it('reports an ITGen HITL without an agent artifact', async () => { + hitls = [appLaunchHitl] + + const [open] = (await poll())!.OpenHitls! + + expect(open.RequestPath).to.be.undefined + expect(open.RequestError).to.contain('no agent artifact') + }) + + it('is absent when the flag is off', async () => { + delete process.env[ATX_ITGEN_ENABLED_ENV_VAR] + hitls = [{ ...testRunHitl, agentArtifact: { artifactId: 'art-run' } }] + + const result = await poll() + + expect(result?.OpenHitls).to.be.undefined + expect(downloadJsonStub.called).to.be.false + }) + + it('is absent for terminal job statuses, which do not list HITLs', async () => { + jobStatus = 'STOPPED' + hitls = [testRunHitl] + + expect((await poll())?.OpenHitls).to.be.undefined + }) + }) + + describe('completeHitlWithTestResults', () => { + let uploadUrlStub: sinon.SinonStub + let uploadStub: sinon.SinonStub + let submitStub: sinon.SinonStub + + const writeZip = (rel: string, entries: Record) => { + const zip = new AdmZip() + for (const [name, content] of Object.entries(entries)) { + zip.addFile(name, Buffer.from(content)) + } + const full = path.join(tmpRoot, rel) + fs.mkdirSync(path.dirname(full), { recursive: true }) + zip.writeZip(full) + return full + } + const summary = (schemaVersion = '1.0') => JSON.stringify({ schemaVersion, runId: 'r2', status: 'COMPLETED' }) + + beforeEach(() => { + uploadUrlStub = sinon + .stub(handler, 'createArtifactUploadUrl') + .resolves({ uploadUrl: 'u', uploadId: 'upload-1', requestHeaders: {} } as any) + uploadStub = sinon.stub(Utils, 'uploadArtifact').resolves(true) + sinon.stub(handler, 'completeArtifactUpload').resolves({ success: true }) + submitStub = sinon.stub(handler, 'submitHitl').resolves({ status: 'SUBMITTED' } as any) + }) + + it('uploads the ZIP as HITL_FROM_USER and submits the task by ID', async () => { + const rel = path.join(workspaceFolderName, JOB_ID, 'itgen', 'r2', 'results.zip') + const full = writeZip(rel, { 'summary.json': summary(), 'trx/SampleWebApi/r2.trx': '' }) + + // Relative to SolutionRootPath, as in the contract example. + const result = await handler.completeHitlWithTestResults(WS_ID, JOB_ID, 'task-run', rel, tmpRoot) + + expect(result).to.deep.equal({ Success: true }) + expect(uploadUrlStub.firstCall.args).to.deep.equal([ + WS_ID, + JOB_ID, + full, + CategoryType.HITL_FROM_USER, + FileType.ZIP, + ]) + expect(uploadStub.firstCall.args[1]).to.equal(full) + expect(submitStub.firstCall.args).to.deep.equal([WS_ID, JOB_ID, 'task-run', 'upload-1']) + }) + + it('refuses when the flag is off', async () => { + delete process.env[ATX_ITGEN_ENABLED_ENV_VAR] + const full = writeZip('results.zip', { 'summary.json': summary() }) + + const result = await handler.completeHitlWithTestResults(WS_ID, JOB_ID, 'task-run', full, tmpRoot) + + expect(result.Success).to.be.false + expect(result.Error).to.contain(ATX_ITGEN_ENABLED_ENV_VAR) + expect(uploadUrlStub.called).to.be.false + }) + + it('refuses a missing ZIP', async () => { + const result = await handler.completeHitlWithTestResults(WS_ID, JOB_ID, 'task-run', 'nope.zip', tmpRoot) + + expect(result.Success).to.be.false + expect(result.Error).to.contain('Result ZIP not found') + }) + + describe('customer changes (workspace/) on the transformed side', () => { + // The updateWorkspace manifest: tracked files, and the job start as the baseline. + const seedManifest = (files: Record, jobStartMsAgo = 60_000) => { + const checkpoints = path.join(tmpRoot, workspaceFolderName, JOB_ID, 'checkpoints') + fs.mkdirSync(checkpoints, { recursive: true }) + const absolute = Object.keys(files).map(rel => path.join(tmpRoot, rel)) + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(tmpRoot, rel)), { recursive: true }) + fs.writeFileSync(path.join(tmpRoot, rel), content) + } + fs.writeFileSync( + path.join(checkpoints, 'source-files.json'), + JSON.stringify({ + createdAt: new Date(Date.now() - jobStartMsAgo).toISOString(), + sourceFiles: absolute, + }) + ) + } + const uploaded = () => new AdmZip(uploadStub.firstCall.args[1]) + const launchResult = (side: string) => + JSON.stringify({ schemaVersion: '1.1', side, status: 'APP_NOT_STARTED', attempt: 2 }) + + it('adds the files the customer changed since job start, in the updateWorkspace format', async () => { + seedManifest({ 'Web/appsettings.json': '{ "fixed": true }' }) + const full = writeZip('launch.zip', { + 'launch-result.json': launchResult('transformed'), + 'app-startup.log': 'boom', + }) + + const result = await handler.completeHitlWithTestResults(WS_ID, JOB_ID, 'task-launch', full, tmpRoot) + + expect(result).to.deep.equal({ Success: true }) + expect(uploadStub.firstCall.args[1]).to.equal(full.replace(/\.zip$/, '.with-workspace.zip')) + const zip = uploaded() + expect(JSON.parse(zip.getEntry('workspace/metadata.json')!.getData().toString())).to.deep.equal({ + filesUpdated: [path.join('Web', 'appsettings.json')], + }) + expect(zip.getEntry('workspace/Web/appsettings.json')!.getData().toString()).to.equal( + '{ "fixed": true }' + ) + // The IDE's own entries are kept, and its ZIP isn't modified. + expect(zip.getEntry('app-startup.log')).to.not.be.null + expect(new AdmZip(full).getEntry('workspace/metadata.json')).to.be.null + }) + + it('adds them to transformed-side test-run results too', async () => { + seedManifest({ 'Web/Controllers/BookController.cs': '// customer edit between runs' }) + const summaryJson = JSON.stringify({ schemaVersion: '1.1', side: 'transformed', status: 'COMPLETED' }) + const full = writeZip('results.zip', { 'summary.json': summaryJson }) + + await handler.completeHitlWithTestResults(WS_ID, JOB_ID, 'task-run', full, tmpRoot) + + expect(uploaded().getEntry('workspace/Web/Controllers/BookController.cs')).to.not.be.null + }) + + it('sends an empty filesUpdated when nothing changed', async () => { + const full = writeZip('launch.zip', { 'launch-result.json': launchResult('transformed') }) + + await handler.completeHitlWithTestResults(WS_ID, JOB_ID, 'task-launch', full, tmpRoot) + + const metadata = uploaded().getEntry('workspace/metadata.json')!.getData().toString() + expect(JSON.parse(metadata)).to.deep.equal({ filesUpdated: [] }) + }) + + it('sends nothing back on the original side', async () => { + seedManifest({ 'Web/appsettings.json': '{}' }) + const full = writeZip('launch.zip', { 'launch-result.json': launchResult('original') }) + + await handler.completeHitlWithTestResults(WS_ID, JOB_ID, 'task-launch', full, tmpRoot) + + expect(uploadStub.firstCall.args[1]).to.equal(full) + expect(uploaded().getEntry('workspace/metadata.json')).to.be.null + }) + + it('keeps a workspace/ the IDE already added', async () => { + seedManifest({ 'Web/appsettings.json': '{}' }) + const full = writeZip('launch.zip', { + 'launch-result.json': launchResult('transformed'), + 'workspace/metadata.json': JSON.stringify({ filesUpdated: [] }), + }) + + await handler.completeHitlWithTestResults(WS_ID, JOB_ID, 'task-launch', full, tmpRoot) + + expect(uploadStub.firstCall.args[1]).to.equal(full) + }) + }) + + it('accepts an app-launch result ZIP (launch-result.json + startup log)', async () => { + const launchResult = JSON.stringify({ schemaVersion: '1.0', status: 'APP_NOT_STARTED', attempt: 1 }) + const full = writeZip('launch.zip', { 'launch-result.json': launchResult, 'app-startup.log': 'boom' }) + + const result = await handler.completeHitlWithTestResults(WS_ID, JOB_ID, 'task-launch', full, tmpRoot) + + expect(result).to.deep.equal({ Success: true }) + expect(submitStub.firstCall.args[2]).to.equal('task-launch') + }) + + it('refuses a launch-result.json with an unsupported schemaVersion', async () => { + const full = writeZip('launch.zip', { 'launch-result.json': JSON.stringify({ schemaVersion: '2.1' }) }) + + const result = await handler.completeHitlWithTestResults(WS_ID, JOB_ID, 'task-launch', full, tmpRoot) + + expect(result.Error).to.equal('Unsupported launch-result.json schemaVersion: 2.1') + expect(uploadUrlStub.called).to.be.false + }) + + it('refuses a ZIP with neither result file, before uploading', async () => { + const full = writeZip('results.zip', { 'trx/SampleWebApi/r2.trx': '' }) + + const result = await handler.completeHitlWithTestResults(WS_ID, JOB_ID, 'task-run', full, tmpRoot) + + expect(result.Error).to.contain('neither summary.json nor launch-result.json') + expect(uploadUrlStub.called).to.be.false + }) + + it('refuses a summary.json with an unsupported schemaVersion', async () => { + const full = writeZip('results.zip', { 'summary.json': summary('2.0') }) + + const result = await handler.completeHitlWithTestResults(WS_ID, JOB_ID, 'task-run', full, tmpRoot) + + expect(result.Error).to.contain('schemaVersion: 2.0') + expect(uploadUrlStub.called).to.be.false + }) + + it('reports a failed submit', async () => { + submitStub.resolves(null) + const full = writeZip('results.zip', { 'summary.json': summary() }) + + const result = await handler.completeHitlWithTestResults(WS_ID, JOB_ID, 'task-run', full, tmpRoot) + + expect(result).to.deep.equal({ Success: false, Error: 'Failed to submit HITL task' }) + }) + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/atxTransformHandler.test.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/atxTransformHandler.test.ts index 1f7fbf5458..50c1efa5e0 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/atxTransformHandler.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/atxTransformHandler.test.ts @@ -1103,6 +1103,197 @@ describe('ATXTransformHandler - getTransformationPlan & helpers', () => { }) }) + describe('downloadCompletedStepArtifacts - ITGen any-depth apply', () => { + const JOB_ID = 'job-itgen' + let tmpRoot: string + let checkpointsDir: string + let sendStub: sinon.SinonStub + let downloadStub: sinon.SinonStub + let applyStub: sinon.SinonStub + + // ITGen plan shape: LBV nested under local-verification, so the error-fix rounds sit at + // depth 3 and the nested LBV step (depth 2) has no diff of its own. + const itGenPlan = () => ({ + Root: { + StepId: 'root', + Status: 'IN_PROGRESS', + Children: [ + { + StepId: 'tp', + Status: 'SUCCEEDED', + Children: [{ StepId: 'proj-a', Status: 'SUCCEEDED', Children: [] }], + }, + { + StepId: 'lv', + Status: 'SUCCEEDED', + Children: [ + { + StepId: 'lbv', + Status: 'SUCCEEDED', + Children: [ + { StepId: 'round-1', Status: 'SUCCEEDED', Children: [] }, + { StepId: 'round-2', Status: 'SUCCEEDED', Children: [] }, + { StepId: 'round-3', Status: 'IN_PROGRESS', Children: [] }, + ], + }, + ], + }, + ], + }, + }) + + // Artifacts tagged to proj-a and the two finished rounds, plus a job-level deliverable. + const listedArtifacts = () => ({ + artifacts: [ + { artifactId: 'a-proj', planStepId: 'proj-a' }, + { artifactId: 'a-r1', planStepId: 'round-1' }, + { artifactId: 'a-r2', planStepId: 'round-2' }, + { artifactId: 'a-final' }, + ], + }) + + const appliedOrder = () => applyStub.getCalls().map(c => path.basename(c.args[0])) + + beforeEach(() => { + process.env.ATX_ITGEN_ENABLED = 'true' + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'atx-itgen-apply-')) + checkpointsDir = path.join(tmpRoot, workspaceFolderName, JOB_ID, 'checkpoints') + sendStub = (handler as any).atxClient.send + sinon.stub(handler as any, 'addAuthToCommand').resolves() + // Materialize the step's checkpoint folder, as a real download would. + downloadStub = sinon + .stub(handler as any, 'downloadCompletedStepArtifact') + .callsFake(async (...args: any[]) => { + fs.mkdirSync(path.join(checkpointsDir, args[2]), { recursive: true }) + }) + applyStub = sinon.stub(handler as any, 'applyChanges').resolves({ success: true }) + sinon.stub(handler as any, 'saveLastAppliedTimestamp') + }) + + afterEach(() => { + delete process.env.ATX_ITGEN_ENABLED + fs.rmSync(tmpRoot, { recursive: true, force: true }) + }) + + it('applies SUCCEEDED steps with an artifact at any depth, children before parents', async () => { + // Give the nested LBV a diff too, to pin that a parent lands after its children. + const listed = listedArtifacts() + listed.artifacts.push({ artifactId: 'a-lbv', planStepId: 'lbv' }) + sendStub.resolves(listed) + + const anyFailed = await (handler as any).downloadCompletedStepArtifacts( + 'ws-1', + JOB_ID, + tmpRoot, + itGenPlan() + ) + + expect(anyFailed).to.be.false + expect(appliedOrder()).to.deep.equal(['proj-a', 'round-1', 'round-2', 'lbv']) + expect(downloadStub.callCount).to.equal(4) + }) + + it('skips SUCCEEDED steps with no artifact instead of reporting a failed apply', async () => { + sendStub.resolves(listedArtifacts()) + ;(handler as any)._currentDiffContext = { failed: false, failedStepIds: [] } + + await (handler as any).downloadCompletedStepArtifacts('ws-1', JOB_ID, tmpRoot, itGenPlan()) + + // tp / lv / lbv have no diff: never downloaded, never flagged. + const downloaded = downloadStub.getCalls().map(c => c.args[2]) + expect(downloaded).to.not.include.members(['tp', 'lv', 'lbv']) + expect((handler as any)._currentDiffContext.failedStepIds).to.deep.equal([]) + }) + + it('pages through ListArtifacts', async () => { + sendStub + .onFirstCall() + .resolves({ artifacts: [{ artifactId: 'a-r1', planStepId: 'round-1' }], nextToken: 't' }) + sendStub.onSecondCall().resolves({ artifacts: [{ artifactId: 'a-r2', planStepId: 'round-2' }] }) + + await (handler as any).downloadCompletedStepArtifacts('ws-1', JOB_ID, tmpRoot, itGenPlan()) + + expect(sendStub.callCount).to.equal(2) + expect(sendStub.secondCall.args[0].input.nextToken).to.equal('t') + expect(appliedOrder()).to.deep.equal(['round-1', 'round-2']) + }) + + it('does not list artifacts once every SUCCEEDED step is applied', async () => { + fs.mkdirSync(checkpointsDir, { recursive: true }) + fs.writeFileSync( + path.join(checkpointsDir, 'checkpoints-applied.json'), + JSON.stringify({ appliedSteps: ['tp', 'proj-a', 'lv', 'lbv', 'round-1', 'round-2'] }) + ) + + const anyFailed = await (handler as any).downloadCompletedStepArtifacts( + 'ws-1', + JOB_ID, + tmpRoot, + itGenPlan() + ) + + expect(anyFailed).to.be.false + expect(sendStub.called).to.be.false + expect(applyStub.called).to.be.false + }) + + it('applies already-downloaded checkpoints without listing', async () => { + fs.mkdirSync(checkpointsDir, { recursive: true }) + fs.writeFileSync( + path.join(checkpointsDir, 'checkpoints-applied.json'), + JSON.stringify({ appliedSteps: ['tp', 'proj-a', 'lv', 'lbv', 'round-1'] }) + ) + fs.mkdirSync(path.join(checkpointsDir, 'round-2')) + + await (handler as any).downloadCompletedStepArtifacts('ws-1', JOB_ID, tmpRoot, itGenPlan()) + + expect(sendStub.called).to.be.false + expect(appliedOrder()).to.deep.equal(['round-2']) + }) + + it('retries on the next poll when ListArtifacts fails, without reporting a failure', async () => { + sendStub.rejects(new Error('throttled')) + + const anyFailed = await (handler as any).downloadCompletedStepArtifacts( + 'ws-1', + JOB_ID, + tmpRoot, + itGenPlan() + ) + + expect(anyFailed).to.be.false + expect(downloadStub.called).to.be.false + expect(applyStub.called).to.be.false + }) + + it('reports a failure when a step with an artifact cannot be downloaded', async () => { + sendStub.resolves({ artifacts: [{ artifactId: 'a-r1', planStepId: 'round-1' }] }) + downloadStub.callsFake(async () => {}) + ;(handler as any)._currentDiffContext = { failed: false, failedStepIds: [] } + + const anyFailed = await (handler as any).downloadCompletedStepArtifacts( + 'ws-1', + JOB_ID, + tmpRoot, + itGenPlan() + ) + + expect(anyFailed).to.be.true + expect((handler as any)._currentDiffContext.failedStepIds).to.deep.equal(['round-1']) + }) + + it('keeps the depth-2-only behavior when the flag is off', async () => { + delete process.env.ATX_ITGEN_ENABLED + sendStub.resolves(listedArtifacts()) + + await (handler as any).downloadCompletedStepArtifacts('ws-1', JOB_ID, tmpRoot, itGenPlan()) + + // Depth 2 only: proj-a and the nested lbv (attempted even without an artifact). + expect(downloadStub.getCalls().map(c => c.args[2])).to.deep.equal(['proj-a', 'lbv']) + expect(sendStub.called).to.be.false + }) + }) + describe('mapApiStatus', () => { it('should pass through valid statuses', () => { expect((handler as any).mapApiStatus('SUCCEEDED')).to.equal('SUCCEEDED')