Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "Feature",
"description": "/transform: Show transformation history in Transformation Hub and allow users to resume jobs"
}
2 changes: 1 addition & 1 deletion packages/amazonq/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,7 @@
},
{
"command": "aws.amazonq.showHistoryInHub",
"title": "%AWS.command.q.transform.viewJobStatus%"
"title": "%AWS.command.q.transform.viewJobHistory%"
},
{
"command": "aws.amazonq.selectCustomization",
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/amazonqGumby/activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just explain this for context?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using TransformationHubViewProvider.instance here to construct the instance implements the singleton pattern and ensures that only one instance exists throughout. That way, when TransformationHubViewProvider.instance is referenced in different parts of the code, we get the same instance every time. I was having issues with accessing the instance with the way it was constructed previously since there seemed to be two instances that existed at the same time.

new ProposedTransformationExplorer(context.extensionContext)
// Register an activation event listener to determine when the IDE opens, closes or users
// select to open a new workspace
Expand Down Expand Up @@ -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
)
}
24 changes: 24 additions & 0 deletions packages/core/src/amazonqGumby/chat/controller/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -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']
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 {
Expand Down
89 changes: 72 additions & 17 deletions packages/core/src/codewhisperer/commands/startTransformByQ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
TransformationType,
TransformationCandidateProject,
RegionProfile,
sessionJobHistory,
} from '../models/model'
import {
createZipManifest,
Expand Down Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the same folder as where the QCT CLI stores artifacts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the CLI stores artifacts in .aws/qcodetransform/ (specifically within the transformation_projects subdirectory)

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()
Expand Down Expand Up @@ -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()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of checking the job status to delete these files, just check if fs.existsSync() to determine whether or not to delete the files

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I was thinking of using status as the check here is because if the job fails or is incomplete on client side, we do not want to delete the metadata file and zip copy since those are needed to resume the job. Is there a better way to do this check?

Sidenote: I will be moving the original zip deletion (lines 757-760) outside of this if clause since that can be deleted regardless

) {
// 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')
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just add some context to the PR why we are getting rid of these so reviewers are aware?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We decided to remove the stopJob() call because with the introduction of the refresh functionality, users can actually resume their jobs within 12 hours. This call was initially added so that jobs that were interrupted on client side would not just run till they time out on the backend. However, with this feature, since users can now continue their jobs, stopping the job on the backend would prevent users from taking this action.

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')
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/codewhisperer/models/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<br><br>' +
'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 {
Expand Down Expand Up @@ -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
}
Loading
Loading