From d600c8996cd3113057a243730dd7e85a95cb65cf Mon Sep 17 00:00:00 2001 From: Megan Mott Date: Wed, 29 Jul 2026 13:41:44 -0700 Subject: [PATCH 1/4] initial --- .../DeploymentPlanViewController.ts | 10 +++++++ .../extension/openDeploymentPlanView.ts | 2 +- .../utils/parseDeploymentPlanMarkdown.ts | 29 ++++++++++++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/webviews/copilotOnRails/extension/controllers/DeploymentPlanViewController.ts b/src/webviews/copilotOnRails/extension/controllers/DeploymentPlanViewController.ts index 3157a2d4f..5b396589c 100644 --- a/src/webviews/copilotOnRails/extension/controllers/DeploymentPlanViewController.ts +++ b/src/webviews/copilotOnRails/extension/controllers/DeploymentPlanViewController.ts @@ -85,6 +85,16 @@ export class DeploymentPlanViewController extends WebviewController name="East US", code="eastus" @@ -79,10 +80,27 @@ export function parseDeploymentPlanMarkdown(markdown: string): DeploymentPlanDat { name: 'East US 2', code: 'eastus2' }, { name: 'West US', code: 'westus' }, { name: 'West US 2', code: 'westus2' }, + { name: 'West US 3', code: 'westus3' }, { name: 'Central US', code: 'centralus' }, + { name: 'South Central US', code: 'southcentralus' }, + { name: 'North Central US', code: 'northcentralus' }, + { name: 'West Central US', code: 'westcentralus' }, + { name: 'Canada Central', code: 'canadacentral' }, + { name: 'Canada East', code: 'canadaeast' }, { name: 'North Europe', code: 'northeurope' }, { name: 'West Europe', code: 'westeurope' }, + { name: 'UK South', code: 'uksouth' }, + { name: 'UK West', code: 'ukwest' }, + { name: 'East Asia', code: 'eastasia' }, { name: 'Southeast Asia', code: 'southeastasia' }, + { name: 'Japan East', code: 'japaneast' }, + { name: 'Japan West', code: 'japanwest' }, + { name: 'Australia East', code: 'australiaeast' }, + { name: 'Australia Southeast', code: 'australiasoutheast' }, + { name: 'Brazil South', code: 'brazilsouth' }, + { name: 'Korea Central', code: 'koreacentral' }, + { name: 'South Africa North', code: 'southafricanorth' }, + { name: 'Sweden Central', code: 'swedencentral' }, ]; let resolvedLocationCode = locationCode; @@ -93,6 +111,10 @@ export function parseDeploymentPlanMarkdown(markdown: string): DeploymentPlanDat if (matched) { resolvedLocationCode = matched.code; resolvedLocation = matched.name; + } else if (/^[a-z][a-z0-9]+$/.test(needle)) { + // Looks like a valid Azure region code not in our list — accept it as-is + resolvedLocationCode = needle; + knownLocations.push({ name: needle, code: needle }); } } @@ -288,6 +310,11 @@ function findAttribute(table: DeploymentPlanTable, attribute: string): string | return row?.[1]?.trim(); } +/** Strips trailing LLM-generated annotations (e.g. "⚠️ ...note...") from a metadata value. */ +function stripAnnotation(value: string): string { + return value.replace(/\s*[\u26A0\u2705\u274C\u2139\u{1F4A1}\u{1F6A8}]\uFE0F?\s.*/su, '').trim() || value; +} + function isWorkspaceTable(table: DeploymentPlanTable): boolean { const headers = normalizedHeaders(table); return headers.includes('component') From a54439a224450b0565a3b7ee35430bde74a01aaf Mon Sep 17 00:00:00 2001 From: Megan Mott Date: Thu, 30 Jul 2026 09:44:03 -0700 Subject: [PATCH 2/4] changes --- .../extension/openDeploymentPlanView.ts | 41 ++++++++++++++++- .../utils/parseDeploymentPlanMarkdown.ts | 45 ++----------------- 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts b/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts index 16e67fa68..04fb0afc9 100644 --- a/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts +++ b/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.md in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { LocationListStep } from "@microsoft/vscode-azext-azureutils"; +import { callWithTelemetryAndErrorHandling, createSubscriptionContext, IActionContext, ISubscriptionActionContext } from "@microsoft/vscode-azext-utils"; import * as vscode from "vscode"; import { ext } from "../../../extensionVariables"; import { DEPLOYMENT_PLAN_FILE_GLOB } from "../../../tree/project/projectPlanFiles"; @@ -35,10 +37,25 @@ export function openDeploymentPlanViewWithContent(content: string, sourceFileUri async function openDeploymentPlanViewWithContentAsync(content: string, sourceFileUri?: vscode.Uri): Promise { const planData = tryParseDeploymentPlan(content, sourceFileUri); - const liveSubscriptions = await getAvailableAzureSubscriptions(); + const [liveSubscriptions, liveLocations] = await Promise.all([ + getAvailableAzureSubscriptions(), + getAvailableAzureLocations(), + ]); if (liveSubscriptions) { planData.availableSubscriptions = liveSubscriptions; } + if (liveLocations) { + planData.availableLocations = liveLocations; + // Resolve the location code from the display name when the plan omitted it. + if (!planData.locationCode && planData.location) { + const needle = planData.location.toLowerCase(); + const matched = liveLocations.find(l => l.name.toLowerCase() === needle || l.code.toLowerCase() === needle); + if (matched) { + planData.locationCode = matched.code; + planData.location = matched.name; + } + } + } host.show(planData, sourceFileUri); } @@ -56,6 +73,28 @@ async function getAvailableAzureSubscriptions(): Promise { } } +async function getAvailableAzureLocations(): Promise<{ name: string; code: string }[] | undefined> { + return await callWithTelemetryAndErrorHandling('copilotOnRails.deploymentPlan.getLocations', async (context: IActionContext) => { + context.errorHandling.rethrow = false; + context.telemetry.suppressIfSuccessful = true; + + const provider = await ext.subscriptionProviderFactory(); + const subscriptions = await provider.getAvailableSubscriptions({ filter: false }); + if (subscriptions.length === 0) { + return undefined; + } + + const wizardContext: ISubscriptionActionContext = { ...context, ...createSubscriptionContext(subscriptions[0]) }; + const locations = await LocationListStep.getLocations(wizardContext); + const mapped = locations + .map(l => ({ name: l.displayName ?? l.name, code: l.name })) + .filter((l): l is { name: string; code: string } => Boolean(l.name && l.code)); + const unique = Array.from(new Map(mapped.map(l => [l.code, l])).values()) + .sort((a, b) => a.name.localeCompare(b.name)); + return unique.length > 0 ? unique : undefined; + }); +} + function tryParseDeploymentPlan(content: string, sourceFileUri: vscode.Uri | undefined): DeploymentPlanData { let parsed: DeploymentPlanData | undefined; let errorMessage: string | undefined; diff --git a/src/webviews/copilotOnRails/views/utils/parseDeploymentPlanMarkdown.ts b/src/webviews/copilotOnRails/views/utils/parseDeploymentPlanMarkdown.ts index b6e597263..9925401fe 100644 --- a/src/webviews/copilotOnRails/views/utils/parseDeploymentPlanMarkdown.ts +++ b/src/webviews/copilotOnRails/views/utils/parseDeploymentPlanMarkdown.ts @@ -75,47 +75,11 @@ export function parseDeploymentPlanMarkdown(markdown: string): DeploymentPlanDat ? ['Visual Studio Enterprise', 'Azure for Students', 'Pay-As-You-Go', 'MSDN Platforms'] : undefined; - const knownLocations = [ - { name: 'East US', code: 'eastus' }, - { name: 'East US 2', code: 'eastus2' }, - { name: 'West US', code: 'westus' }, - { name: 'West US 2', code: 'westus2' }, - { name: 'West US 3', code: 'westus3' }, - { name: 'Central US', code: 'centralus' }, - { name: 'South Central US', code: 'southcentralus' }, - { name: 'North Central US', code: 'northcentralus' }, - { name: 'West Central US', code: 'westcentralus' }, - { name: 'Canada Central', code: 'canadacentral' }, - { name: 'Canada East', code: 'canadaeast' }, - { name: 'North Europe', code: 'northeurope' }, - { name: 'West Europe', code: 'westeurope' }, - { name: 'UK South', code: 'uksouth' }, - { name: 'UK West', code: 'ukwest' }, - { name: 'East Asia', code: 'eastasia' }, - { name: 'Southeast Asia', code: 'southeastasia' }, - { name: 'Japan East', code: 'japaneast' }, - { name: 'Japan West', code: 'japanwest' }, - { name: 'Australia East', code: 'australiaeast' }, - { name: 'Australia Southeast', code: 'australiasoutheast' }, - { name: 'Brazil South', code: 'brazilsouth' }, - { name: 'Korea Central', code: 'koreacentral' }, - { name: 'South Africa North', code: 'southafricanorth' }, - { name: 'Sweden Central', code: 'swedencentral' }, - ]; - let resolvedLocationCode = locationCode; - let resolvedLocation = location; - if (resolvedLocationCode === 'unknown' && location !== 'Unknown') { - const needle = location.toLowerCase(); - const matched = knownLocations.find(l => l.name.toLowerCase() === needle || l.code.toLowerCase() === needle); - if (matched) { - resolvedLocationCode = matched.code; - resolvedLocation = matched.name; - } else if (/^[a-z][a-z0-9]+$/.test(needle)) { - // Looks like a valid Azure region code not in our list — accept it as-is - resolvedLocationCode = needle; - knownLocations.push({ name: needle, code: needle }); - } + const resolvedLocation = location; + if (resolvedLocationCode === 'unknown' && location !== 'Unknown' && /^[a-z][a-z0-9]+$/.test(location.toLowerCase())) { + // The location was authored as a bare Azure region code (e.g. `eastus`). + resolvedLocationCode = location.toLowerCase(); } return { @@ -125,7 +89,6 @@ export function parseDeploymentPlanMarkdown(markdown: string): DeploymentPlanDat availableSubscriptions, location: resolvedLocation === 'Unknown' ? '' : resolvedLocation, locationCode: resolvedLocationCode === 'unknown' ? '' : resolvedLocationCode, - availableLocations: knownLocations, architecture, workspaceScan: workspaceCandidate?.table ?? emptyTable(), decisions: decisionsCandidate?.table ?? emptyTable(), From 101ec9b6332e5c960e76f83beb8089e54ffc165e Mon Sep 17 00:00:00 2001 From: Megan Mott Date: Thu, 30 Jul 2026 10:49:44 -0700 Subject: [PATCH 3/4] requested change --- .../copilotOnRails/extension/openDeploymentPlanView.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts b/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts index 04fb0afc9..72439f4e0 100644 --- a/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts +++ b/src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts @@ -9,6 +9,7 @@ import * as vscode from "vscode"; import { ext } from "../../../extensionVariables"; import { DEPLOYMENT_PLAN_FILE_GLOB } from "../../../tree/project/projectPlanFiles"; import { CopilotOnRailsContext } from "../../../utils/copilotOnRails/CopilotOnRailsContext"; +import { corId } from "../../../utils/copilotOnRails/telemetryUtils"; import type { DeploymentPlanData } from "../views/utils/deploymentPlanTypes"; import { getDeploymentPlanRenderIssue, parseDeploymentPlanMarkdown } from "../views/utils/parseDeploymentPlanMarkdown"; import { DeploymentPlanViewController } from "./controllers/DeploymentPlanViewController"; @@ -74,7 +75,7 @@ async function getAvailableAzureSubscriptions(): Promise { } async function getAvailableAzureLocations(): Promise<{ name: string; code: string }[] | undefined> { - return await callWithTelemetryAndErrorHandling('copilotOnRails.deploymentPlan.getLocations', async (context: IActionContext) => { + return await callWithTelemetryAndErrorHandling(corId('deploymentPlan.getLocations'), async (context: IActionContext) => { context.errorHandling.rethrow = false; context.telemetry.suppressIfSuccessful = true; From feff6d284bc5b31cab41f81be35b7f0407ef0419 Mon Sep 17 00:00:00 2001 From: Megan Mott Date: Wed, 5 Aug 2026 08:39:24 -0700 Subject: [PATCH 4/4] more changes --- resources/agents/azure-deploy.agent.md | 47 +++++++++++++++---- .../DeploymentPlanViewController.ts | 1 - .../views/DeploymentPlanView.tsx | 18 ++----- .../utils/parseDeploymentPlanMarkdown.ts | 4 +- .../views/utils/viewConfigTypes.ts | 1 - 5 files changed, 43 insertions(+), 28 deletions(-) diff --git a/resources/agents/azure-deploy.agent.md b/resources/agents/azure-deploy.agent.md index b142348ce..db14aa8f5 100644 --- a/resources/agents/azure-deploy.agent.md +++ b/resources/agents/azure-deploy.agent.md @@ -23,15 +23,42 @@ Never claim one of these tools is "not available" or "not exposed", never fall b The phases below are **strictly ordered**. You **must not** start a later phase until the earlier one has completed: -1. Write `.azure/deployment-plan.md` (the `azure-prepare` skill calls this the deployment plan). -2. **Step A** — open the deployment plan preview (see below). Mandatory. -3. **Step B** — wait for the user's explicit approval of the deployment plan. Mandatory. -4. Generate the deployment artifacts (infra, `azure.yaml`, Dockerfiles, etc.) as directed by the `azure-prepare` skill, following the **azure.yaml hook rules** below. -5. **Step C** — validate the artifacts with `azd package` before declaring the deployment ready. Mandatory. +1. Write the `.azure/deployment-plan.md` skeleton (the `azure-prepare` skill calls this the deployment plan). +2. **Step A** — confirm subscription and location, then validate quotas (see below). Mandatory before presenting the plan. +3. Finalize `.azure/deployment-plan.md` with all fields populated — no `_TBD_`, `⚠️ MUST confirm`, or blank cells. +4. **Step B** — open the deployment plan preview (see below). Mandatory. +5. **Step C** — wait for the user's explicit approval of the deployment plan. Mandatory. +6. Generate the deployment artifacts (infra, `azure.yaml`, Dockerfiles, etc.) as directed by the `azure-prepare` skill, following the **azure.yaml hook rules** below. +7. **Step D** — validate the artifacts with `azd package` before declaring the deployment ready. Mandatory. -### Step A — open the deployment plan preview (MANDATORY, do not skip) +### Step A — confirm subscription, location, and quotas BEFORE presenting the plan (MANDATORY) -**Trigger:** the instant the `azure-prepare` skill finishes writing `.azure/deployment-plan.md` to disk. This must happen **before** the skill's approval gate (before you summarize the plan or ask for approval). +The `azure-prepare` skill's plan template marks Subscription and Location as `⚠️ MUST confirm with user`, and its Provisioning Limit Checklist (Section 6) requires completed quota data with no `_TBD_` entries. Both of these **must be resolved before** you open the plan preview or present the plan. + +Follow `azure-prepare/references/azure-context.md` for the exact flow: + +1. Check for an existing AZD environment (`azd env list` / `azd env get-values`). +2. If no environment exists or the user wants different settings, detect defaults (`azd config get defaults`, fall back to `az account show`). +3. **Ask the user** to confirm the subscription (showing the actual name and ID). +4. **Ask the user** to confirm the location (showing only regions that support all planned services). +5. **Validate quotas** — invoke the `azure-quotas` skill to populate the Provisioning Limit Checklist. Every row must have actual numbers; no `_TBD_` or placeholder values. +6. Record the confirmed subscription, location, and quota results in `.azure/deployment-plan.md`. + +Only after all six sub-steps succeed should you finalize the plan and proceed to Step B. + +### Plan completeness gate (MANDATORY) + +Before opening the plan preview (Step B), verify that `.azure/deployment-plan.md` satisfies **all** of these: + +- **Section 2 (Requirements):** Subscription and Location rows contain actual values (not `⚠️ MUST confirm with user`). +- **Section 6 (Provisioning Limit Checklist):** Every row in Phase 2 has numeric values for Total After Deployment, Limit/Quota, and Notes. No cells contain `_TBD_` or `_To be filled in Phase 2_`. +- **All template sections** from `azure-prepare/references/plan-template.md` are present. Do not omit, rename, or reorder sections. + +If any check fails, go back and resolve it before continuing. Do **not** present an incomplete plan. + +### Step B — open the deployment plan preview (MANDATORY, do not skip) + +**Trigger:** the instant the plan is finalized and passes the completeness gate above. This must happen **before** you summarize the plan or ask for approval. **Action — call the `open_deploy_plan_view` tool immediately, before any other output.** It takes no arguments. @@ -39,9 +66,9 @@ There is no file-watcher fallback — if you skip this call, the user will not s This is not optional and not conditional. Do not summarize the plan, do not ask the user a question, do not begin generating infrastructure, and do not move on until this tool has been called. If the tool returns an error, report it verbatim — but still attempt the call first. -### Step B — require explicit user approval before generating artifacts +### Step C — require explicit user approval before generating artifacts -After Step A, **stop and wait** for explicit user approval of the deployment plan. Do **not** begin generating Bicep/Terraform/`azure.yaml`/Dockerfiles until the user confirms. Treat anything other than a clear approval (e.g. questions, edits, "looks good but…") as not-yet-approved. +After Step B, **stop and wait** for explicit user approval of the deployment plan. Do **not** begin generating Bicep/Terraform/`azure.yaml`/Dockerfiles until the user confirms. Treat anything other than a clear approval (e.g. questions, edits, "looks good but…") as not-yet-approved. ### azure.yaml hook rules (avoid the deploy retry loop) @@ -64,7 +91,7 @@ hooks: run: ./scripts/seed-data.ps1 ``` -### Step C — validate the generated artifacts before declaring success (MANDATORY) +### Step D — validate the generated artifacts before declaring success (MANDATORY) After generating `azure.yaml` and the infra, **run `azd package`** (from the workspace root) to validate the manifest and confirm the app's build output is produced (for a Functions app, that the host actually discovers functions). Do **not** report the deployment as ready — and do **not** enter a retry-`azd deploy` loop — until `azd package` succeeds. diff --git a/src/webviews/copilotOnRails/extension/controllers/DeploymentPlanViewController.ts b/src/webviews/copilotOnRails/extension/controllers/DeploymentPlanViewController.ts index 5b396589c..68c6e66a6 100644 --- a/src/webviews/copilotOnRails/extension/controllers/DeploymentPlanViewController.ts +++ b/src/webviews/copilotOnRails/extension/controllers/DeploymentPlanViewController.ts @@ -38,7 +38,6 @@ function getDeploymentPlanViewStrings(): DeploymentPlanViewStrings { feedbackButtonAriaLabel: vscode.l10n.t('Feedback'), feedbackButtonTooltip: vscode.l10n.t('Request changes to the plan before approving'), approveButtonTooltip: vscode.l10n.t('Approve the plan and continue with Copilot'), - approveButtonAlreadyApprovedTooltip: vscode.l10n.t('Plan already approved'), approveButtonMissingSelectionTooltip: vscode.l10n.t('Select a subscription and location before approving the plan'), feedbackDrawerInfoTooltip: vscode.l10n.t('Your feedback will be sent to Copilot as a prompt. Copilot will revise the plan and update the file. The updated plan will reload here for your final approval.'), revisingBanner: vscode.l10n.t('Copilot is revising the plan…'), diff --git a/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx b/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx index 2308deba8..ca1592070 100644 --- a/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx +++ b/src/webviews/copilotOnRails/views/DeploymentPlanView.tsx @@ -79,16 +79,6 @@ export const DeploymentPlanView = (): JSX.Element => { [feedbackItems, freeformDraft], ); - const isAlreadyApproved = useMemo(() => { - const s = plan?.status?.trim().toLowerCase(); - return s === 'approved'; - }, [plan?.status]); - - const missingRequiredSelection = useMemo( - () => !plan?.subscription?.trim() || !plan?.locationCode?.trim(), - [plan?.subscription, plan?.locationCode], - ); - const editedRows = useMemo(() => { const set = new Set(); for (const item of feedbackItems) { @@ -123,7 +113,7 @@ export const DeploymentPlanView = (): JSX.Element => { }, []); const handleApprove = useCallback(() => { - if (!plan || isAlreadyApproved || missingRequiredSelection) { + if (!plan) { return; } if (hasEdits) { @@ -131,7 +121,7 @@ export const DeploymentPlanView = (): JSX.Element => { return; } vscodeApi.postMessage({ command: 'approve', data: plan }); - }, [vscodeApi, plan, hasEdits, isAlreadyApproved, missingRequiredSelection]); + }, [vscodeApi, plan, hasEdits]); const handleSubscriptionChange = useCallback((value: string) => { if (!plan) { return; } @@ -404,13 +394,13 @@ export const DeploymentPlanView = (): JSX.Element => { />