Skip to content
Open
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
47 changes: 37 additions & 10 deletions resources/agents/azure-deploy.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,52 @@ 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.

There is no file-watcher fallback — if you skip this call, the user will not see the plan preview.

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)

Expand All @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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…'),
Expand Down Expand Up @@ -85,6 +84,16 @@ export class DeploymentPlanViewController extends WebviewController<DeploymentPl
case 'approve':
void this.approvePlan();
break;
case 'subscriptionChanged':
if (typeof message.data === 'string') {
this.planData.subscription = message.data;
}
break;
case 'locationChanged':
if (typeof message.data === 'string') {
this.planData.locationCode = message.data;
}
break;
case 'submitPlanFeedback': {
const query = message.prompt?.trim();
if (!query) {
Expand Down
44 changes: 42 additions & 2 deletions src/webviews/copilotOnRails/extension/openDeploymentPlanView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@
* 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 { CopilotOnRailsContext } from "../../../utils/copilotOnRails/CopilotOnRailsContext";
import { DEPLOYMENT_PLAN_FILE_GLOB } from "../../../tree/project/projectPlanFiles";
import { CopilotOnRailsContext } from "../../../utils/copilotOnRails/CopilotOnRailsContext";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Any idea why this got moved? I don't see any obvious difference

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Probably just formatting differences I'll move it back

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";
Expand Down Expand Up @@ -35,10 +38,25 @@ export function openDeploymentPlanViewWithContent(content: string, sourceFileUri

async function openDeploymentPlanViewWithContentAsync(content: string, sourceFileUri?: vscode.Uri): Promise<void> {
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);
}
Expand All @@ -56,6 +74,28 @@ async function getAvailableAzureSubscriptions(): Promise<string[] | undefined> {
}
}

async function getAvailableAzureLocations(): Promise<{ name: string; code: string }[] | undefined> {
return await callWithTelemetryAndErrorHandling(corId('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;
Expand Down
18 changes: 4 additions & 14 deletions src/webviews/copilotOnRails/views/DeploymentPlanView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>();
for (const item of feedbackItems) {
Expand Down Expand Up @@ -123,15 +113,15 @@ export const DeploymentPlanView = (): JSX.Element => {
}, []);

const handleApprove = useCallback(() => {
if (!plan || isAlreadyApproved || missingRequiredSelection) {
if (!plan) {
return;
}
if (hasEdits) {
setConfirmSubmitOpen(true);
return;
}
vscodeApi.postMessage({ command: 'approve', data: plan });
}, [vscodeApi, plan, hasEdits, isAlreadyApproved, missingRequiredSelection]);
}, [vscodeApi, plan, hasEdits]);

const handleSubscriptionChange = useCallback((value: string) => {
if (!plan) { return; }
Expand Down Expand Up @@ -404,13 +394,13 @@ export const DeploymentPlanView = (): JSX.Element => {
/>
</Tooltip>
<Tooltip
content={isAlreadyApproved ? strings.approveButtonAlreadyApprovedTooltip : missingRequiredSelection ? strings.approveButtonMissingSelectionTooltip : strings.approveButtonTooltip}
content={strings.approveButtonTooltip}
relationship='label'
>
<Button
appearance='primary'
icon={<CheckmarkRegular />}
disabled={isAwaitingRevision || isAlreadyApproved || missingRequiredSelection}
disabled={isAwaitingRevision}
onClick={handleApprove}
>
{strings.approveButton}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ export function parseDeploymentPlanMarkdown(markdown: string): DeploymentPlanDat

const status = extractMetadata(lines, 'Status') ?? 'Unknown';
const mode = extractMetadata(lines, 'Mode') ?? 'Unknown';
const subscription = extractMetadata(lines, 'Subscription') ?? findAttribute(requirements, 'Subscription') ?? 'Unknown';
const rawSubscription = extractMetadata(lines, 'Subscription') ?? findAttribute(requirements, 'Subscription') ?? 'Unknown';
const subscription = stripAnnotation(rawSubscription) || 'Unknown';
const rawLocation = extractMetadata(lines, 'Location') ?? findAttribute(requirements, 'Location') ?? 'Unknown';

// Parse location: "East US (`eastus`)" -> name="East US", code="eastus"
Expand Down Expand Up @@ -74,26 +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: 'Central US', code: 'centralus' },
{ name: 'North Europe', code: 'northeurope' },
{ name: 'West Europe', code: 'westeurope' },
{ name: 'Southeast Asia', code: 'southeastasia' },
];

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;
}
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 {
Expand All @@ -103,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(),
Expand Down Expand Up @@ -288,6 +273,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();
}

function isWorkspaceTable(table: DeploymentPlanTable): boolean {
const headers = normalizedHeaders(table);
return headers.includes('component')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ export type DeploymentPlanViewStrings = {
feedbackButtonAriaLabel: string;
feedbackButtonTooltip: string;
approveButtonTooltip: string;
approveButtonAlreadyApprovedTooltip: string;
approveButtonMissingSelectionTooltip: string;
feedbackDrawerInfoTooltip: string;
revisingBanner: string;
Expand Down
Loading