diff --git a/packages/amazonq/.changes/next-release/Feature-dffec708-ae10-45d7-bcfd-b1c07a84de12.json b/packages/amazonq/.changes/next-release/Feature-dffec708-ae10-45d7-bcfd-b1c07a84de12.json new file mode 100644 index 00000000000..ec459d083f3 --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-dffec708-ae10-45d7-bcfd-b1c07a84de12.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "/transform: Show transformation history in Transformation Hub and allow users to resume jobs" +} diff --git a/packages/amazonq/package.json b/packages/amazonq/package.json index 071f2000031..c155a4604be 100644 --- a/packages/amazonq/package.json +++ b/packages/amazonq/package.json @@ -746,7 +746,7 @@ }, { "command": "aws.amazonq.showHistoryInHub", - "title": "%AWS.command.q.transform.viewJobStatus%" + "title": "%AWS.command.q.transform.viewJobHistory%" }, { "command": "aws.amazonq.selectCustomization", diff --git a/packages/core/package.nls.json b/packages/core/package.nls.json index 45db449c625..06343f17c75 100644 --- a/packages/core/package.nls.json +++ b/packages/core/package.nls.json @@ -281,7 +281,7 @@ "AWS.command.q.transform.rejectChanges": "Reject", "AWS.command.q.transform.stopJobInHub": "Stop job", "AWS.command.q.transform.viewJobProgress": "View job progress", - "AWS.command.q.transform.viewJobStatus": "View job status", + "AWS.command.q.transform.viewJobHistory": "View job history", "AWS.command.q.transform.showTransformationPlan": "View plan", "AWS.command.q.transform.showChangeSummary": "View summary", "AWS.command.threatComposer.createNew": "Create New Threat Composer File", diff --git a/packages/core/src/amazonqGumby/activation.ts b/packages/core/src/amazonqGumby/activation.ts index 74823f6fbc6..8ab47f5697e 100644 --- a/packages/core/src/amazonqGumby/activation.ts +++ b/packages/core/src/amazonqGumby/activation.ts @@ -21,7 +21,7 @@ import { setContext } from '../shared/vscode/setContext' export async function activate(context: ExtContext) { void setContext('gumby.wasQCodeTransformationUsed', false) - const transformationHubViewProvider = new TransformationHubViewProvider() + const transformationHubViewProvider = TransformationHubViewProvider.instance new ProposedTransformationExplorer(context.extensionContext) // Register an activation event listener to determine when the IDE opens, closes or users // select to open a new workspace @@ -72,6 +72,13 @@ export async function activate(context: ExtContext) { ) }), + Commands.register( + 'aws.amazonq.transformationHub.updateContent', + async (button, startTime, historyFileUpdated) => { + await transformationHubViewProvider.updateContent(button, startTime, historyFileUpdated) + } + ), + workspaceChangeEvent ) } diff --git a/packages/core/src/amazonqGumby/chat/controller/controller.ts b/packages/core/src/amazonqGumby/chat/controller/controller.ts index 7e3e799a046..ae277ca24f9 100644 --- a/packages/core/src/amazonqGumby/chat/controller/controller.ts +++ b/packages/core/src/amazonqGumby/chat/controller/controller.ts @@ -57,6 +57,8 @@ import { } from '../../../codewhisperer/service/transformByQ/transformFileHandler' import { getAuthType } from '../../../auth/utils' import fs from '../../../shared/fs/fs' +import { setContext } from '../../../shared/vscode/setContext' +import { readHistoryFile } from '../../../codewhisperer/service/transformByQ/transformationHubViewProvider' // These events can be interactions within the chat, // or elsewhere in the IDE @@ -188,6 +190,15 @@ export class GumbyController { } private async transformInitiated(message: any) { + // check if any jobs potentially still in progress on backend + const history = await readHistoryFile() + const numInProgress = history.filter((job) => job.status === 'FAILED').length + this.messenger.sendViewHistoryMessage(message.tabID, numInProgress) + if (transformByQState.isRefreshInProgress()) { + this.messenger.sendMessage(CodeWhispererConstants.refreshInProgressChatMessage, message.tabID, 'ai-prompt') + return + } + // silently check for projects eligible for SQL conversion let embeddedSQLProjects: TransformationCandidateProject[] = [] try { @@ -383,6 +394,11 @@ export class GumbyController { case ButtonActions.VIEW_TRANSFORMATION_HUB: await vscode.commands.executeCommand(GumbyCommands.FOCUS_TRANSFORMATION_HUB, CancelActionPositions.Chat) break + case ButtonActions.VIEW_JOB_HISTORY: + await setContext('gumby.wasQCodeTransformationUsed', true) + await vscode.commands.executeCommand(GumbyCommands.FOCUS_TRANSFORMATION_HUB) + await vscode.commands.executeCommand(GumbyCommands.FOCUS_JOB_HISTORY, CancelActionPositions.Chat) + break case ButtonActions.VIEW_SUMMARY: await vscode.commands.executeCommand('aws.amazonq.transformationHub.summary.reveal') break @@ -452,6 +468,10 @@ export class GumbyController { } private async handleUserLanguageUpgradeProjectChoice(message: any) { + if (transformByQState.isRefreshInProgress()) { + this.messenger.sendMessage(CodeWhispererConstants.refreshInProgressChatMessage, message.tabID, 'ai-prompt') + return + } await telemetry.codeTransform_submitSelection.run(async () => { const pathToProject: string = message.formSelectedValues['GumbyTransformLanguageUpgradeProjectForm'] const toJDKVersion: JDKVersion = message.formSelectedValues['GumbyTransformJdkToForm'] @@ -484,6 +504,10 @@ export class GumbyController { } private async handleUserSQLConversionProjectSelection(message: any) { + if (transformByQState.isRefreshInProgress()) { + this.messenger.sendMessage(CodeWhispererConstants.refreshInProgressChatMessage, message.tabID, 'ai-prompt') + return + } await telemetry.codeTransform_submitSelection.run(async () => { const pathToProject: string = message.formSelectedValues['GumbyTransformSQLConversionProjectForm'] const schema: string = message.formSelectedValues['GumbyTransformSQLSchemaForm'] diff --git a/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts b/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts index 699e3b77938..59c144a8605 100644 --- a/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts +++ b/packages/core/src/amazonqGumby/chat/controller/messenger/messenger.ts @@ -377,6 +377,38 @@ export class Messenger { this.dispatcher.sendChatMessage(jobSubmittedMessage) } + public sendViewHistoryMessage(tabID: string, numInProgress: number) { + const buttons: ChatItemButton[] = [] + + buttons.push({ + keepCardAfterClick: true, + text: CodeWhispererConstants.jobHistoryButtonText, + id: ButtonActions.VIEW_JOB_HISTORY, + disabled: false, + }) + + const messageText = CodeWhispererConstants.viewHistoryMessage(numInProgress) + + const message = new ChatMessage( + { + message: messageText, + messageType: 'ai-prompt', + buttons, + }, + tabID + ) + this.dispatcher.sendChatMessage(message) + } + + public sendJobRefreshInProgressMessage(tabID: string, jobId: string) { + this.dispatcher.sendAsyncEventProgress( + new AsyncEventProgressMessage(tabID, { + inProgress: true, + message: CodeWhispererConstants.refreshingJobChatMessage(jobId), + }) + ) + } + public sendMessage(prompt: string, tabID: string, type: 'prompt' | 'ai-prompt') { this.dispatcher.sendChatMessage( new ChatMessage( diff --git a/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts b/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts index 4df65fe9d1d..2c64a050547 100644 --- a/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts +++ b/packages/core/src/amazonqGumby/chat/controller/messenger/messengerUtils.ts @@ -13,6 +13,7 @@ import DependencyVersions from '../../../models/dependencies' export enum ButtonActions { STOP_TRANSFORMATION_JOB = 'gumbyStopTransformationJob', VIEW_TRANSFORMATION_HUB = 'gumbyViewTransformationHub', + VIEW_JOB_HISTORY = 'gumbyViewJobHistory', VIEW_SUMMARY = 'gumbyViewSummary', CONFIRM_LANGUAGE_UPGRADE_TRANSFORMATION_FORM = 'gumbyLanguageUpgradeTransformFormConfirm', CONFIRM_SQL_CONVERSION_TRANSFORMATION_FORM = 'gumbySQLConversionTransformFormConfirm', @@ -33,6 +34,7 @@ export enum GumbyCommands { CLEAR_CHAT = 'aws.awsq.clearchat', START_TRANSFORMATION_FLOW = 'aws.awsq.transform', FOCUS_TRANSFORMATION_HUB = 'aws.amazonq.showTransformationHub', + FOCUS_JOB_HISTORY = 'aws.amazonq.showHistoryInHub', } export default class MessengerUtils { diff --git a/packages/core/src/codewhisperer/commands/startTransformByQ.ts b/packages/core/src/codewhisperer/commands/startTransformByQ.ts index 56e54a97a8a..209b9628a73 100644 --- a/packages/core/src/codewhisperer/commands/startTransformByQ.ts +++ b/packages/core/src/codewhisperer/commands/startTransformByQ.ts @@ -20,6 +20,7 @@ import { TransformationType, TransformationCandidateProject, RegionProfile, + sessionJobHistory, } from '../models/model' import { createZipManifest, @@ -474,6 +475,30 @@ export async function startTransformationJob( codeTransformRunTimeLatency: calculateTotalLatency(transformStartTime), }) }) + + // create local history folder(s) and store metadata + const jobHistoryPath = path.join(os.homedir(), '.aws', 'transform', transformByQState.getProjectName(), jobId) + if (!fs.existsSync(jobHistoryPath)) { + fs.mkdirSync(jobHistoryPath, { recursive: true }) + } + transformByQState.setJobHistoryPath(jobHistoryPath) + // save a copy of the upload zip + fs.copyFileSync(transformByQState.getPayloadFilePath(), path.join(jobHistoryPath, 'zipped-code.zip')) + + const fields = [ + jobId, + transformByQState.getTransformationType(), + transformByQState.getSourceJDKVersion(), + transformByQState.getTargetJDKVersion(), + transformByQState.getCustomDependencyVersionFilePath(), + transformByQState.getCustomBuildCommand(), + transformByQState.getTargetJavaHome(), + transformByQState.getProjectPath(), + transformByQState.getStartTime(), + ] + + const jobDetails = fields.join('\t') + fs.writeFileSync(path.join(jobHistoryPath, 'metadata.txt'), jobDetails) } catch (error) { getLogger().error(`CodeTransformation: ${CodeWhispererConstants.failedToStartJobNotification}`, error) const errorMessage = (error as Error).message.toLowerCase() @@ -724,9 +749,18 @@ export async function postTransformationJob() { }) } - if (transformByQState.getPayloadFilePath()) { - // delete original upload ZIP at very end of transformation - fs.rmSync(transformByQState.getPayloadFilePath(), { force: true }) + // delete original upload ZIP at very end of transformation + fs.rmSync(transformByQState.getPayloadFilePath(), { force: true }) + + if ( + transformByQState.isSucceeded() || + transformByQState.isPartiallySucceeded() || + transformByQState.isCancelled() + ) { + // delete the copy of the upload ZIP + fs.rmSync(path.join(transformByQState.getJobHistoryPath(), 'zipped-code.zip'), { force: true }) + // delete transformation job metadata file (no longer needed) + fs.rmSync(path.join(transformByQState.getJobHistoryPath(), 'metadata.txt'), { force: true }) } // delete temporary build logs file const logFilePath = path.join(os.tmpdir(), 'build-logs.txt') @@ -739,31 +773,52 @@ export async function postTransformationJob() { if (transformByQState.isSucceeded() || transformByQState.isPartiallySucceeded()) { await vscode.commands.executeCommand('aws.amazonq.transformationHub.reviewChanges.startReview') } + + // store job details and diff path locally (history) + // TODO: ideally when job is cancelled, should be stored as CANCELLED instead of FAILED (remove this if statement after bug is fixed) + if (!transformByQState.isCancelled()) { + const historyLogFilePath = path.join(os.homedir(), '.aws', 'transform', 'transformation_history.tsv') + // create transform folder if necessary + if (!fs.existsSync(historyLogFilePath)) { + fs.mkdirSync(path.dirname(historyLogFilePath), { recursive: true }) + // create headers of new transformation history file + fs.writeFileSync(historyLogFilePath, 'date\tproject_name\tstatus\tduration\tdiff_patch\tsummary\tjob_id\n') + } + const latest = sessionJobHistory[transformByQState.getJobId()] + const fields = [ + latest.startTime, + latest.projectName, + latest.status, + latest.duration, + transformByQState.isSucceeded() || transformByQState.isPartiallySucceeded() + ? path.join(transformByQState.getJobHistoryPath(), 'diff.patch') + : '', + transformByQState.isSucceeded() || transformByQState.isPartiallySucceeded() + ? path.join(transformByQState.getJobHistoryPath(), 'summary', 'summary.md') + : '', + transformByQState.getJobId(), + ] + + const jobDetails = fields.join('\t') + '\n' + fs.writeFileSync(historyLogFilePath, jobDetails, { flag: 'a' }) // 'a' flag used to append to file + await vscode.commands.executeCommand( + 'aws.amazonq.transformationHub.updateContent', + 'job history', + undefined, + true + ) + } } export async function transformationJobErrorHandler(error: any) { if (!transformByQState.isCancelled()) { // means some other error occurred; cancellation already handled by now with stopTransformByQ - await stopJob(transformByQState.getJobId()) transformByQState.setToFailed() transformByQState.setPolledJobStatus('FAILED') // jobFailureErrorNotification should always be defined here - const displayedErrorMessage = - transformByQState.getJobFailureErrorNotification() ?? CodeWhispererConstants.failedToCompleteJobNotification transformByQState.setJobFailureErrorChatMessage( transformByQState.getJobFailureErrorChatMessage() ?? CodeWhispererConstants.failedToCompleteJobChatMessage ) - void vscode.window - .showErrorMessage(displayedErrorMessage, CodeWhispererConstants.amazonQFeedbackText) - .then((choice) => { - if (choice === CodeWhispererConstants.amazonQFeedbackText) { - void submitFeedback( - placeholder, - CodeWhispererConstants.amazonQFeedbackKey, - getFeedbackCommentData() - ) - } - }) } else { transformByQState.setToCancelled() transformByQState.setPolledJobStatus('CANCELLED') diff --git a/packages/core/src/codewhisperer/models/constants.ts b/packages/core/src/codewhisperer/models/constants.ts index 703b21d671e..4db98727765 100644 --- a/packages/core/src/codewhisperer/models/constants.ts +++ b/packages/core/src/codewhisperer/models/constants.ts @@ -799,6 +799,34 @@ export const formattedStringMap = new Map([ ['numChangedFiles', 'Files to be changed'], ]) +export const refreshInProgressChatMessage = 'A job refresh is currently in progress. Please wait for it to complete.' + +export const refreshingJobChatMessage = (jobId: string) => + `I am now resuming your job (id: ${jobId}). This can take 10 to 30 minutes to complete.` + +export const jobHistoryButtonText = 'Open job history' + +export const viewHistoryMessage = (numInProgress: number) => + numInProgress > 0 + ? `You have ${numInProgress} job${numInProgress > 1 ? 's' : ''} in progress. You can resume ${numInProgress > 1 ? 'them' : 'it'} in the transformation history table.` + : 'View previous transformations run from the IDE' + +export const transformationHistoryTableDescription = + 'This table lists the most recent jobs that you have run in the past 30 days. To open the diff patch and summary files, click the provided links. To get an updated job status, click the refresh icon. The diff patch and summary will appear once they are available.

' + + 'Jobs with a status of FAILED may still be in progress. Resume these jobs within 12 hours of starting the job to get an updated job status and artifacts.' + +export const refreshErrorChatMessage = + "Sorry, I couldn't refresh the job. Please try again or start a new transformation." + +export const refreshErrorNotification = (jobId: string) => `There was an error refreshing this job. Job Id: ${jobId}` + +export const refreshCompletedChatMessage = + 'Job refresh completed. Please see the transformation history table for the updated status and artifacts.' + +export const refreshCompletedNotification = (jobId: string) => `Job refresh completed. (Job Id: ${jobId})` + +export const refreshNoUpdatesNotification = (jobId: string) => `No updates. (Job Id: ${jobId})` + // end of QCT Strings export enum UserGroup { @@ -912,3 +940,14 @@ export const codeReviewFindingsSuffix = '_codeReviewFindings' export const displayFindingsSuffix = '_displayFindings' export const displayFindingsDetectorName = 'DisplayFindings' +export const findingsSuffix = '_codeReviewFindings' + +export interface HistoryObject { + startTime: string + projectName: string + status: string + duration: string + diffPath: string + summaryPath: string + jobId: string +} diff --git a/packages/core/src/codewhisperer/models/model.ts b/packages/core/src/codewhisperer/models/model.ts index 7681c34e613..bcfa50c6a71 100644 --- a/packages/core/src/codewhisperer/models/model.ts +++ b/packages/core/src/codewhisperer/models/model.ts @@ -730,6 +730,7 @@ export class TransformByQState { private planFilePath: string = '' private summaryFilePath: string = '' private preBuildLogFilePath: string = '' + private jobHistoryPath: string = '' private resultArchiveFilePath: string = '' private projectCopyFilePath: string = '' @@ -761,6 +762,8 @@ export class TransformByQState { private intervalId: NodeJS.Timeout | undefined = undefined + private refreshInProgress: boolean = false + public isNotStarted() { return this.transformByQState === TransformByQStatus.NotStarted } @@ -785,6 +788,10 @@ export class TransformByQState { return this.transformByQState === TransformByQStatus.PartiallySucceeded } + public isRefreshInProgress() { + return this.refreshInProgress + } + public getHasSeenTransforming() { return this.hasSeenTransforming } @@ -881,6 +888,10 @@ export class TransformByQState { return this.summaryFilePath } + public getJobHistoryPath() { + return this.jobHistoryPath + } + public getResultArchiveFilePath() { return this.resultArchiveFilePath } @@ -975,6 +986,10 @@ export class TransformByQState { this.transformByQState = TransformByQStatus.PartiallySucceeded } + public setRefreshInProgress(inProgress: boolean) { + this.refreshInProgress = inProgress + } + public setHasSeenTransforming(hasSeen: boolean) { this.hasSeenTransforming = hasSeen } @@ -1055,6 +1070,10 @@ export class TransformByQState { this.summaryFilePath = filePath } + public setJobHistoryPath(filePath: string) { + this.jobHistoryPath = filePath + } + public setResultArchiveFilePath(filePath: string) { this.resultArchiveFilePath = filePath } @@ -1121,6 +1140,7 @@ export class TransformByQState { public setJobDefaults() { this.setToNotStarted() + this.refreshInProgress = false this.hasSeenTransforming = false this.jobFailureErrorNotification = undefined this.jobFailureErrorChatMessage = undefined @@ -1137,6 +1157,7 @@ export class TransformByQState { this.buildLog = '' this.customBuildCommand = '' this.intervalId = undefined + this.jobHistoryPath = '' } } diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts b/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts index 052ef53b56c..fe09e203919 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformationHubViewProvider.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode' import globals from '../../../shared/extensionGlobals' import * as CodeWhispererConstants from '../../models/constants' import { + JDKVersion, StepProgress, TransformationType, jobPlanProgress, @@ -14,30 +15,41 @@ import { transformByQState, } from '../../models/model' import { getLogger } from '../../../shared/logger/logger' -import { getTransformationSteps } from './transformApiHandler' +import { getTransformationSteps, downloadAndExtractResultArchive } from './transformApiHandler' import { TransformationSteps, ProgressUpdates, TransformationStatus, } from '../../../codewhisperer/client/codewhispereruserclient' -import { startInterval } from '../../commands/startTransformByQ' +import { codeWhispererClient } from '../../../codewhisperer/client/codewhisperer' +import { startInterval, pollTransformationStatusUntilComplete } from '../../commands/startTransformByQ' import { CodeTransformTelemetryState } from '../../../amazonqGumby/telemetry/codeTransformTelemetryState' -import { convertToTimeString } from '../../../shared/datetime' +import { convertToTimeString, isWithin30Days } from '../../../shared/datetime' import { AuthUtil } from '../../util/authUtil' +import fs from '../../../shared/fs/fs' +import path from 'path' +import os from 'os' +import { ChatSessionManager } from '../../../amazonqGumby/chat/storages/chatSession' +import { setMaven } from './transformFileHandler' export class TransformationHubViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'aws.amazonq.transformationHub' private _view?: vscode.WebviewView private lastClickedButton: string = '' private _extensionUri: vscode.Uri = globals.context.extensionUri + private transformationHistory: CodeWhispererConstants.HistoryObject[] = [] constructor() {} static #instance: TransformationHubViewProvider public async updateContent( button: 'job history' | 'plan progress', - startTime: number = CodeTransformTelemetryState.instance.getStartTime() + startTime: number = CodeTransformTelemetryState.instance.getStartTime(), + historyFileUpdated?: boolean ) { this.lastClickedButton = button + if (historyFileUpdated) { + this.transformationHistory = await readHistoryFile() + } if (this._view) { if (this.lastClickedButton === 'job history') { clearInterval(transformByQState.getIntervalId()) @@ -62,18 +74,33 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider return (this.#instance ??= new this()) } - public resolveWebviewView( + public async resolveWebviewView( webviewView: vscode.WebviewView, context: vscode.WebviewViewResolveContext, token: vscode.CancellationToken - ): void | Thenable { + ) { this._view = webviewView + this._view.webview.onDidReceiveMessage((message) => { + switch (message.command) { + case 'refreshJob': + void this.refreshJob(message.jobId, message.currentStatus, message.projectName) + break + case 'openSummaryPreview': + void vscode.commands.executeCommand('markdown.showPreview', vscode.Uri.file(message.filePath)) + break + case 'openDiffFile': + void vscode.commands.executeCommand('vscode.open', vscode.Uri.file(message.filePath)) + break + } + }) + this._view.webview.options = { enableScripts: true, localResourceRoots: [this._extensionUri], } + this.transformationHistory = await readHistoryFile() if (this.lastClickedButton === 'job history') { this._view!.webview.html = this.showJobHistory() } else { @@ -88,6 +115,19 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider } private showJobHistory(): string { + const jobsToDisplay: CodeWhispererConstants.HistoryObject[] = [...this.transformationHistory] + if (transformByQState.isRunning()) { + const current = sessionJobHistory[transformByQState.getJobId()] + jobsToDisplay.unshift({ + startTime: current.startTime, + projectName: current.projectName, + status: current.status, + duration: current.duration, + diffPath: '', + summaryPath: '', + jobId: transformByQState.getJobId(), + }) + } return ` @@ -99,18 +139,69 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider -

Transformation Status

+

Transformation History

+

${CodeWhispererConstants.transformationHistoryTableDescription}

${ - Object.keys(sessionJobHistory).length === 0 - ? `

${CodeWhispererConstants.nothingToShowMessage}

` - : this.getTableMarkup(sessionJobHistory[transformByQState.getJobId()]) + jobsToDisplay.length === 0 + ? `


${CodeWhispererConstants.nothingToShowMessage}

` + : this.getTableMarkup(jobsToDisplay) } + ` } - private getTableMarkup(job: { startTime: string; projectName: string; status: string; duration: string }) { + private getTableMarkup(history: CodeWhispererConstants.HistoryObject[]) { return ` + @@ -118,22 +209,288 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider - + + + + - - - - - - - + ${history + .map( + (job) => ` + + + + + + + + + + + ` + ) + .join('')}
Project Status DurationIdDiff PatchSummary FileJob IdRefresh Job
${job.startTime}${job.projectName}${job.status}${job.duration}${transformByQState.getJobId()}
${job.startTime}${job.projectName}${job.status === 'FAILED_BE' ? 'FAILED' : job.status}${job.duration}${job.diffPath ? `diff.patch` : ''}${job.summaryPath ? `summary.md` : ''}${job.jobId} + +
` } + private async refreshJob(jobId: string, currentStatus: string, projectName: string) { + // fetch status from server + let status = '' + let duration = '' + if (currentStatus === 'COMPLETED' || currentStatus === 'PARTIALLY_COMPLETED') { + // job is already completed, no need to fetch status + status = currentStatus + } else { + try { + const response = await codeWhispererClient.codeModernizerGetCodeTransformation({ + transformationJobId: jobId, + profileArn: undefined, + }) + status = response.transformationJob.status ?? currentStatus + if (response.transformationJob.endExecutionTime && response.transformationJob.creationTime) { + duration = convertToTimeString( + response.transformationJob.endExecutionTime.getTime() - + response.transformationJob.creationTime.getTime() + ) + } + + getLogger().debug( + 'Code Transformation: Job refresh - Fetched status for job id: %s\n{Status: %s; Duration: %s}', + jobId, + status, + duration + ) + } catch (error) { + getLogger().error( + 'Code Transformation: Error fetching status (job id: %s): %s', + jobId, + (error as Error).message + ) + return + } + } + + // retrieve artifacts and updated duration if available + let jobHistoryPath: string = '' + if (status === 'COMPLETED' || status === 'PARTIALLY_COMPLETED') { + // artifacts should be available to download + jobHistoryPath = await this.retrieveArtifacts(jobId, projectName) + + // delete metadata and zipped code files, if they exist + await fs.delete(path.join(os.homedir(), '.aws', 'transform', projectName, jobId, 'metadata.txt'), { + force: true, + }) + await fs.delete(path.join(os.homedir(), '.aws', 'transform', projectName, jobId, 'zipped-code.zip'), { + force: true, + }) + } else if (CodeWhispererConstants.validStatesForBuildSucceeded.includes(status)) { + // still in progress on server side + if (transformByQState.isRunning()) { + getLogger().warn( + 'Code Transformation: There is a job currently running (id: %s). Cannot resume another job (id: %s)', + transformByQState.getJobId(), + jobId + ) + return + } + transformByQState.setRefreshInProgress(true) + const messenger = transformByQState.getChatMessenger() + const tabID = ChatSessionManager.Instance.getSession().tabID + messenger?.sendJobRefreshInProgressMessage(tabID!, jobId) + void this.updateContent('job history') // refreshing the table disables all jobs' refresh buttons while this one is resuming + + // resume job and bring to completion + try { + status = await this.resumeJob(jobId, projectName, status) + } catch (e: any) { + getLogger().error('Code Transformation: Error resuming job (id: %s): %s', jobId, (e as Error).message) + transformByQState.setJobDefaults() + messenger?.sendJobFinishedMessage(tabID!, CodeWhispererConstants.refreshErrorChatMessage) + void vscode.window.showErrorMessage(CodeWhispererConstants.refreshErrorNotification(jobId)) + void this.updateContent('job history') + return + } + + // download artifacts if available + if ( + CodeWhispererConstants.validStatesForCheckingDownloadUrl.includes(status) && + !CodeWhispererConstants.failureStates.includes(status) + ) { + duration = convertToTimeString(Date.now() - new Date(transformByQState.getStartTime()).getTime()) + jobHistoryPath = await this.retrieveArtifacts(jobId, projectName) + } + + // reset state + transformByQState.setJobDefaults() + messenger?.sendJobFinishedMessage(tabID!, CodeWhispererConstants.refreshCompletedChatMessage) + } else { + // FAILED or STOPPED job + getLogger().info('Code Transformation: No artifacts available to download (job status = %s)', status) + if (status === 'FAILED') { + // if job failed on backend, mark it to disable the refresh button + status = 'FAILED_BE' // this will be truncated to just 'FAILED' in the table + } + // delete metadata and zipped code files, if they exist + await fs.delete(path.join(os.homedir(), '.aws', 'transform', projectName, jobId, 'metadata.txt'), { + force: true, + }) + await fs.delete(path.join(os.homedir(), '.aws', 'transform', projectName, jobId, 'zipped-code.zip'), { + force: true, + }) + } + + if (status === currentStatus && !jobHistoryPath) { + // no changes, no need to update file/table + void vscode.window.showInformationMessage(CodeWhispererConstants.refreshNoUpdatesNotification(jobId)) + return + } + + void vscode.window.showInformationMessage(CodeWhispererConstants.refreshCompletedNotification(jobId)) + // update local file and history table + await this.updateHistoryFile(status, duration, jobHistoryPath, jobId) + } + + private async retrieveArtifacts(jobId: string, projectName: string) { + const resultsPath = path.join(os.homedir(), '.aws', 'transform', projectName, 'results') // temporary directory for extraction + let jobHistoryPath = path.join(os.homedir(), '.aws', 'transform', projectName, jobId) + + if (await fs.existsFile(path.join(jobHistoryPath, 'diff.patch'))) { + getLogger().info('Code Transformation: Diff patch already exists for job id: %s', jobId) + jobHistoryPath = '' + } else { + try { + await downloadAndExtractResultArchive(jobId, resultsPath) + + if (!(await fs.existsDir(path.join(jobHistoryPath, 'summary')))) { + await fs.mkdir(path.join(jobHistoryPath, 'summary')) + } + await fs.copy(path.join(resultsPath, 'patch', 'diff.patch'), path.join(jobHistoryPath, 'diff.patch')) + await fs.copy( + path.join(resultsPath, 'summary', 'summary.md'), + path.join(jobHistoryPath, 'summary', 'summary.md') + ) + if (await fs.existsFile(path.join(resultsPath, 'summary', 'buildCommandOutput.log'))) { + await fs.copy( + path.join(resultsPath, 'summary', 'buildCommandOutput.log'), + path.join(jobHistoryPath, 'summary', 'buildCommandOutput.log') + ) + } + } catch (error) { + jobHistoryPath = '' + } finally { + // delete temporary extraction directory + await fs.delete(resultsPath, { recursive: true, force: true }) + } + } + return jobHistoryPath + } + + private async updateHistoryFile(status: string, duration: string, jobHistoryPath: string, jobId: string) { + const history: string[][] = [] + const historyLogFilePath = path.join(os.homedir(), '.aws', 'transform', 'transformation_history.tsv') + if (await fs.existsFile(historyLogFilePath)) { + const historyFile = await fs.readFileText(historyLogFilePath) + const jobs = historyFile.split('\n') + jobs.shift() // removes headers + if (jobs.length > 0) { + for (const job of jobs) { + if (job) { + const jobInfo = job.split('\t') + // startTime: jobInfo[0], projectName: jobInfo[1], status: jobInfo[2], duration: jobInfo[3], diffPath: jobInfo[4], summaryPath: jobInfo[5], jobId: jobInfo[6] + if (jobInfo[6] === jobId) { + // update any values if applicable + jobInfo[2] = status + if (duration) { + jobInfo[3] = duration + } + if (jobHistoryPath) { + jobInfo[4] = path.join(jobHistoryPath, 'diff.patch') + jobInfo[5] = path.join(jobHistoryPath, 'summary', 'summary.md') + } + } + history.push(jobInfo) + } + } + } + } + + if (history.length === 0) { + return + } + + // rewrite file + await fs.writeFile(historyLogFilePath, 'date\tproject_name\tstatus\tduration\tdiff_patch\tsummary\tjob_id\n') + const tsvContent = history.map((row) => row.join('\t')).join('\n') + '\n' + await fs.appendFile(historyLogFilePath, tsvContent) + + // update table content + await this.updateContent('job history', undefined, true) + } + + private async resumeJob(jobId: string, projectName: string, status: string) { + // set state to prepare to resume job + await this.setupTransformationState(jobId, projectName, status) + // resume polling the job + return await this.pollAndCompleteTransformation(jobId) + } + + private async setupTransformationState(jobId: string, projectName: string, status: string) { + transformByQState.setJobId(jobId) + transformByQState.setPolledJobStatus(status) + transformByQState.setJobHistoryPath(path.join(os.homedir(), '.aws', 'transform', projectName, jobId)) + const metadataFile = await fs.readFileText(path.join(transformByQState.getJobHistoryPath(), 'metadata.txt')) + const metadata = metadataFile.split('\t') + transformByQState.setTransformationType(metadata[1] as TransformationType) + transformByQState.setSourceJDKVersion(metadata[2] as JDKVersion) + transformByQState.setTargetJDKVersion(metadata[3] as JDKVersion) + transformByQState.setCustomDependencyVersionFilePath(metadata[4]) + transformByQState.setPayloadFilePath( + path.join(os.homedir(), '.aws', 'transform', projectName, jobId, 'zipped-code.zip') + ) + setMaven() + transformByQState.setCustomBuildCommand(metadata[5]) + transformByQState.setTargetJavaHome(metadata[6]) + transformByQState.setProjectPath(metadata[7]) + transformByQState.setStartTime(metadata[8]) + } + + private async pollAndCompleteTransformation(jobId: string) { + const status = await pollTransformationStatusUntilComplete( + jobId, + AuthUtil.instance.regionProfileManager.activeRegionProfile + ) + // delete payload and metadata files + await fs.delete(transformByQState.getPayloadFilePath(), { force: true }) + await fs.delete(path.join(transformByQState.getJobHistoryPath(), 'metadata.txt'), { force: true }) + // delete temporary build logs file + const logFilePath = path.join(os.tmpdir(), 'build-logs.txt') + await fs.delete(logFilePath, { force: true }) + return status + } + private generateTransformationStepMarkup( name: string, startTime: Date | undefined, @@ -541,3 +898,34 @@ export class TransformationHubViewProvider implements vscode.WebviewViewProvider } } } + +export async function readHistoryFile(): Promise { + const history: CodeWhispererConstants.HistoryObject[] = [] + const jobHistoryFilePath = path.join(os.homedir(), '.aws', 'transform', 'transformation_history.tsv') + + if (!(await fs.existsFile(jobHistoryFilePath))) { + return history + } + + const historyFile = await fs.readFileText(jobHistoryFilePath) + const jobs = historyFile.split('\n') + jobs.shift() // removes headers + + // Process from end, stop at 10 valid entries + for (let i = jobs.length - 1; i >= 0 && history.length < 10; i--) { + const job = jobs[i] + if (job && isWithin30Days(job.split('\t')[0])) { + const jobInfo = job.split('\t') + history.push({ + startTime: jobInfo[0], + projectName: jobInfo[1], + status: jobInfo[2], + duration: jobInfo[3], + diffPath: jobInfo[4], + summaryPath: jobInfo[5], + jobId: jobInfo[6], + }) + } + } + return history +} diff --git a/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts b/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts index 0b678f8120d..7bb4427437a 100644 --- a/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts +++ b/packages/core/src/codewhisperer/service/transformByQ/transformationResultsViewProvider.ts @@ -426,6 +426,7 @@ export class ProposedTransformationExplorer { let deserializeErrorMessage = undefined let pathContainingArchive = '' patchFiles = [] // reset patchFiles if there was a previous transformation + try { // Download and deserialize the zip pathContainingArchive = path.dirname(pathToArchive) @@ -433,6 +434,7 @@ export class ProposedTransformationExplorer { zip.extractAllTo(pathContainingArchive) const files = fs.readdirSync(path.join(pathContainingArchive, ExportResultArchiveStructure.PathToPatch)) singlePatchFile = path.join(pathContainingArchive, ExportResultArchiveStructure.PathToPatch, files[0]) + fs.copyFileSync(singlePatchFile, path.join(transformByQState.getJobHistoryPath(), 'diff.patch')) // store diff patch locally patchFiles.push(singlePatchFile) diffModel.parseDiff(patchFiles[0], transformByQState.getProjectPath()) @@ -441,6 +443,25 @@ export class ProposedTransformationExplorer { transformByQState.setSummaryFilePath( path.join(pathContainingArchive, ExportResultArchiveStructure.PathToSummary) ) + // store summary and build log locally for history + if (!fs.existsSync(path.join(transformByQState.getJobHistoryPath(), 'summary'))) { + fs.mkdirSync(path.join(transformByQState.getJobHistoryPath(), 'summary')) + } + fs.copyFileSync( + transformByQState.getSummaryFilePath(), + path.join(transformByQState.getJobHistoryPath(), 'summary', 'summary.md') + ) + if ( + fs.existsSync( + path.join(path.dirname(transformByQState.getSummaryFilePath()), 'buildCommandOutput.log') + ) + ) { + fs.copyFileSync( + path.join(path.dirname(transformByQState.getSummaryFilePath()), 'buildCommandOutput.log'), + path.join(transformByQState.getJobHistoryPath(), 'summary', 'buildCommandOutput.log') + ) + } + transformByQState.setResultArchiveFilePath(pathContainingArchive) await setContext('gumby.isSummaryAvailable', true) diff --git a/packages/core/src/shared/datetime.ts b/packages/core/src/shared/datetime.ts index 6123421666a..8043f94d343 100644 --- a/packages/core/src/shared/datetime.ts +++ b/packages/core/src/shared/datetime.ts @@ -154,3 +154,25 @@ export function formatDateTimestamp(forceUTC: boolean, d: Date = new Date()): st // trim 'Z' (last char of iso string) and add offset string return `${iso.substring(0, iso.length - 1)}${offsetString}` } + +/** + * Checks if a given timestamp is within 30 days of the current day + * @param timeStamp + * @returns true if timeStamp is within 30 days, false otherwise + */ +export function isWithin30Days(timeStamp: string): boolean { + if (!timeStamp) { + return false // No timestamp given + } + + const startDate = new Date(timeStamp) + const currentDate = new Date() + + // Calculate the difference in milliseconds + const timeDifference = currentDate.getTime() - startDate.getTime() + + // Convert milliseconds to days (1000ms * 60s * 60min * 24hr) + const daysDifference = timeDifference / (1000 * 60 * 60 * 24) + + return daysDifference <= 30 +} diff --git a/packages/core/src/test/amazonqGumby/transformationJobHistory.test.ts b/packages/core/src/test/amazonqGumby/transformationJobHistory.test.ts new file mode 100644 index 00000000000..4e485553415 --- /dev/null +++ b/packages/core/src/test/amazonqGumby/transformationJobHistory.test.ts @@ -0,0 +1,466 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import * as CodeWhispererConstants from '../../codewhisperer/models/constants' +import { transformByQState, sessionJobHistory } from '../../codewhisperer/models/model' +import { codeWhispererClient } from '../../codewhisperer/client/codewhisperer' +import { + TransformationHubViewProvider, + readHistoryFile, +} from '../../codewhisperer/service/transformByQ/transformationHubViewProvider' +import fs from '../../shared/fs/fs' +import nodeFs from 'fs' // eslint-disable-line no-restricted-imports +import { postTransformationJob } from '../../codewhisperer/commands/startTransformByQ' +import * as transformApiHandler from '../../codewhisperer/service/transformByQ/transformApiHandler' +import * as vscode from 'vscode' + +describe('Transformation Job History', function () { + let transformationHub: TransformationHubViewProvider + + // Mock job objects + const mockJobs = { + completed: { + startTime: '07/14/25, 09:00 AM', + projectName: 'old-project', + status: 'COMPLETED', + duration: '3 min', + diffPath: '/path/to/diff.patch', + summaryPath: '/path/to/summary.md', + jobId: 'old-job-456', + } as CodeWhispererConstants.HistoryObject, + + transforming: { + startTime: '07/14/25, 10:00 AM', + projectName: 'incomplete-project', + status: 'TRANSFORMING', + duration: '3 min', + diffPath: '', + summaryPath: '', + jobId: 'inc-100', + } as CodeWhispererConstants.HistoryObject, + + failed: { + startTime: '07/14/25, 09:00 AM', + projectName: 'old-project', + status: 'FAILED', + duration: '3 min', + diffPath: '', + summaryPath: '', + jobId: 'fail-100', + } as CodeWhispererConstants.HistoryObject, + + failedBE: { + startTime: '07/10/25, 10:00 AM', + projectName: 'failed-project', + status: 'FAILED_BE', + duration: '3 min', + diffPath: '', + summaryPath: '', + jobId: 'failbe-300', + } as CodeWhispererConstants.HistoryObject, + + stopped: { + startTime: '07/14/25, 10:00 AM', + projectName: 'cancelled-project', + status: 'STOPPED', + duration: '3 min', + diffPath: '', + summaryPath: '', + jobId: 'stop-200', + } as CodeWhispererConstants.HistoryObject, + } + + // setup function helpers + function setupRunningJob(jobId = 'running-job-123') { + sinon.stub(transformByQState, 'isRunning').returns(true) + sinon.stub(transformByQState, 'getJobId').returns(jobId) + sessionJobHistory[jobId] = { + startTime: '07/14/25, 11:00 AM', + projectName: 'running-project', + status: 'TRANSFORMING', + duration: '2 min', + } + return jobId + } + + beforeEach(function () { + transformationHub = TransformationHubViewProvider.instance + }) + + afterEach(function () { + sinon.restore() + }) + + describe('Viewing job history in Transformation Hub', function () { + it('Nothing to show message when no history', function () { + transformationHub['transformationHistory'] = [] + sinon.stub(transformByQState, 'isRunning').returns(false) + + const result = transformationHub['showJobHistory']() + + assert(result.includes('Transformation History')) + assert(result.includes(CodeWhispererConstants.nothingToShowMessage)) + }) + + it('Can see previously run jobs', function () { + transformationHub['transformationHistory'] = [mockJobs.completed, mockJobs.transforming, mockJobs.failedBE] + sinon.stub(transformByQState, 'isRunning').returns(false) + + const result = transformationHub['showJobHistory']() + + assert(result.includes('old-project')) + assert(result.includes('COMPLETED')) + assert(result.includes('old-job-456')) + assert(result.includes('incomplete-project')) + assert(result.includes('TRANSFORMING')) + assert(result.includes('inc-100')) + assert(!result.includes('FAILED_BE'), 'Table should only say FAILED in the status column') + assert(result.includes(']*disabled`, 'i') + const incompleteJobButtonRegex = new RegExp(`row-id="fail-100"[^>]*disabled`, 'i') + const completedJobButtonRegex = new RegExp(`row-id="old-job-456"[^>]*disabled`, 'i') + assert( + runningJobButtonRegex.test(result) && + incompleteJobButtonRegex.test(result) && + completedJobButtonRegex.test(result), + "All jobs' refresh buttons should be disabled" + ) + }) + + it('Cannot click refresh button of STOPPED jobs', function () { + transformationHub['transformationHistory'] = [mockJobs.completed, mockJobs.stopped] + + sinon.stub(transformByQState, 'isRunning').returns(false) + + const result = transformationHub['showJobHistory']() + + const runningJobButtonRegex = new RegExp(`row-id="stop-200"[^>]*disabled`, 'i') + assert(runningJobButtonRegex.test(result), "STOPPED job's refresh button should be disabled") + }) + + it('Cannot click refresh button of jobs that failed on backend', function () { + transformationHub['transformationHistory'] = [mockJobs.failed, mockJobs.failedBE] + + sinon.stub(transformByQState, 'isRunning').returns(false) + + const result = transformationHub['showJobHistory']() + + const runningJobButtonRegex = new RegExp(`row-id="failbe-300"[^>]*disabled`, 'i') + assert(runningJobButtonRegex.test(result), "FAILED_BE job's refresh button should be disabled") + const completedJobButtonRegex = new RegExp(`row-id="fail-100"[^>]*disabled`, 'i') + assert(!completedJobButtonRegex.test(result), "Incomplete (FAILED) job's refresh button should be enabled") + }) + }) + + describe('Refreshing jobs', function () { + describe('Updating status', function () { + let codeWhispererClientStub: sinon.SinonStub + + beforeEach(function () { + codeWhispererClientStub = sinon.stub(codeWhispererClient, 'codeModernizerGetCodeTransformation') + }) + + it('Does not fetch status for already completed jobs', async function () { + sinon.stub(transformationHub as any, 'retrieveArtifacts').resolves('') // TODO: refactor TransformationHubViewProvider and extract private methods + sinon.stub(transformationHub as any, 'updateHistoryFile').resolves() + + await transformationHub['refreshJob']('job-123', 'COMPLETED', 'test-project') + sinon.assert.notCalled(codeWhispererClientStub) + + await transformationHub['refreshJob']('job-456', 'PARTIALLY_COMPLETED', 'test-project2') + sinon.assert.notCalled(codeWhispererClientStub) + }) + + it('Fetches updated status', async function () { + const mockResponse = { + transformationJob: { + status: 'COMPLETED', + endExecutionTime: new Date(), + creationTime: new Date(Date.now() - 60000), // 1 minute ago + }, + } + codeWhispererClientStub.resolves(mockResponse) + sinon.stub(transformationHub as any, 'retrieveArtifacts').resolves('') + sinon.stub(transformationHub as any, 'updateHistoryFile').resolves() + + await transformationHub['refreshJob']('job-123', 'FAILED', 'test-project') + sinon.assert.calledOnce(codeWhispererClientStub) + }) + }) + + describe('Downloading artifacts', function () { + it('Does not download artifacts when diff patch already exists', async function () { + const fsExistsStub = sinon.stub(fs, 'existsFile').resolves(true) + const jobHistoryPath = await transformationHub['retrieveArtifacts']('job-123', 'test-project') + + sinon.assert.called(fsExistsStub) + assert.strictEqual(jobHistoryPath, '', 'Should return empty string when diff already exists') + }) + + it('Does not attempt to download artifacts for FAILED/STOPPED jobs', async function () { + const mockResponse = { + transformationJob: { + status: 'STOPPED', + endExecutionTime: new Date(), + creationTime: new Date(Date.now() - 60000), + }, + } as any + const codeWhispererClientStub = sinon + .stub(codeWhispererClient, 'codeModernizerGetCodeTransformation') + .resolves(mockResponse) + const retrieveArtifactsStub = sinon.stub(transformationHub as any, 'retrieveArtifacts') + sinon.stub(transformationHub as any, 'updateHistoryFile').resolves() + + await transformationHub['refreshJob']('job-123', 'FAILED', 'test-project') + + sinon.assert.calledOnce(codeWhispererClientStub) + sinon.assert.notCalled(retrieveArtifactsStub) + }) + }) + + describe('Updating history file', function () { + let fsWriteStub: sinon.SinonStub + let fsAppendStub: sinon.SinonStub + + // mocks and setup + const mockHistoryContent = + 'date\tproject_name\tstatus\tduration\tdiff_patch\tsummary\tjob_id\n' + + '07/14/25, 09:00 AM\ttest-project\tFAILED\t5 min\t\t\tjob-123\n' + + '07/14/25, 10:00 AM\tother-project\tCOMPLETED\t3 min\t/path/diff.patch\t/path/summary.md\tjob-456\n' + + function createMockTransformationResponse(status: string, timeOffset = 300000) { + return { + transformationJob: { + status, + endExecutionTime: new Date(), + creationTime: new Date(Date.now() - timeOffset), + }, + } as any + } + + function setupRefreshJobTest(mockResponse: any) { + const codeWhispererClientStub = sinon + .stub(codeWhispererClient, 'codeModernizerGetCodeTransformation') + .resolves(mockResponse) + const retrieveArtifactsStub = sinon.stub(transformationHub as any, 'retrieveArtifacts').resolves('') + + return { codeWhispererClientStub, retrieveArtifactsStub } + } + + beforeEach(function () { + fsWriteStub = sinon.stub(fs, 'writeFile').resolves() + fsAppendStub = sinon.stub(fs, 'appendFile').resolves() + sinon.stub(fs, 'readFileText').resolves(mockHistoryContent) + sinon.stub(fs, 'existsFile').resolves(true) + }) + + it('Updates existing job entry in history file', async function () { + const mockResponse = createMockTransformationResponse('STOPPED') + const { codeWhispererClientStub, retrieveArtifactsStub } = setupRefreshJobTest(mockResponse) + + await transformationHub['refreshJob']('job-123', 'FAILED', 'test-project') + + sinon.assert.called(fsAppendStub) + const writtenContent = fsAppendStub.args[0][1] + const updatedJobLine = writtenContent.split('\n').find((line: string) => line.includes('job-123')) + assert(updatedJobLine.includes('STOPPED'), 'Status should be updated to STOPPED') + assert(updatedJobLine.includes('5 min'), 'Duration should remain 5 min') + const unchangedJobLine = writtenContent.split('\n').find((line: string) => line.includes('job-456')) + assert(unchangedJobLine) + sinon.assert.calledOnce(codeWhispererClientStub) + sinon.assert.notCalled(retrieveArtifactsStub) + }) + + it('Updates history file when job FAILED on backend', async function () { + const mockResponse = createMockTransformationResponse('FAILED') + const { codeWhispererClientStub, retrieveArtifactsStub } = setupRefreshJobTest(mockResponse) + + await transformationHub['refreshJob']('job-123', 'FAILED', 'test-project') + + sinon.assert.called(fsWriteStub) + sinon.assert.called(fsAppendStub) + const writtenContent = fsAppendStub.args[0][1] + const updatedJobLine = writtenContent.split('\n').find((line: string) => line.includes('job-123')) + assert(updatedJobLine.includes('FAILED_BE'), 'Status should be updated to FAILED_BE') + assert(updatedJobLine.includes('5 min'), 'Duration should remain 5 min') + sinon.assert.calledOnce(codeWhispererClientStub) + sinon.assert.notCalled(retrieveArtifactsStub) + }) + + it('Does not update history file when no changes are needed', async function () { + const mockResponse = { + transformationJob: { + status: 'COMPLETED', + endExecutionTime: new Date(), + creationTime: new Date(Date.now() - 60000), + }, + } as any + + const codeWhispererClientStub = sinon + .stub(codeWhispererClient, 'codeModernizerGetCodeTransformation') + .resolves(mockResponse) + sinon.stub(transformationHub as any, 'retrieveArtifacts').resolves('') + const updateHistoryFileStub = sinon.stub(transformationHub as any, 'updateHistoryFile').resolves() + + await transformationHub['refreshJob']('job-123', 'COMPLETED', 'test-project') + + sinon.assert.notCalled(codeWhispererClientStub) + sinon.assert.notCalled(updateHistoryFileStub) + }) + + it('Updates content in the UI after updating history file', async function () { + const updateContentStub = sinon.stub(transformationHub, 'updateContent').resolves() + await transformationHub['updateHistoryFile']('COMPLETED', '5 min', '/new/path', 'job-123') + sinon.assert.calledWith(updateContentStub, 'job history', undefined, true) + }) + }) + }) +})