diff --git a/.CodeQL.yml b/.CodeQL.yml
new file mode 100644
index 00000000000..71d797b7904
--- /dev/null
+++ b/.CodeQL.yml
@@ -0,0 +1,10 @@
+# This file configures CodeQL scanning path exclusions.
+# For more information, see:
+# https://eng.ms/docs/cloud-ai-platform/devdiv/one-engineering-system-1es/1es-docs/codeql/troubleshooting/bugs/generated-library-code
+
+path_classifiers:
+ refs:
+ # The playground and tests directories are not product code,
+ # so they don't need to be scanned by CodeQL.
+ - playground/**
+ - tests/**
diff --git a/.github/workflows/auto-rerun-transient-ci-failures.js b/.github/workflows/auto-rerun-transient-ci-failures.js
index 41a996ee9ac..83b4bb788a9 100644
--- a/.github/workflows/auto-rerun-transient-ci-failures.js
+++ b/.github/workflows/auto-rerun-transient-ci-failures.js
@@ -28,6 +28,11 @@ const ignoredFailureStepPatterns = [
/^Install dependencies$/i,
];
+const testExecutionFailureStepPatterns = [
+ /^Run tests\b/i,
+ /^Run nuget dependent tests\b/i,
+];
+
const transientAnnotationPatterns = [
/The job was not acquired by Runner of type hosted even after multiple attempts/i,
/The hosted runner lost communication with the server/i,
@@ -72,25 +77,29 @@ const windowsProcessInitializationFailurePatterns = [
/\b0xC0000142\b/i,
];
-const feedNetworkFailureStepPatterns = [
- /^Install sdk for nuget based testing$/i,
- /^Build test project$/i,
- /^Build and archive test project$/i,
- /^Build with packages$/i,
- /^Build RID-specific packages\b/i,
- /^Build .*validation image$/i,
- /^Run .*SDK validation$/i,
- /^Rebuild for Azure Functions project$/i,
-];
-
-const ignoredBuildFailureLogOverridePatterns = [
+const infrastructureNetworkFailureLogOverridePatterns = [
/Unable to load the service index for source https:\/\/(?:pkgs\.dev\.azure\.com\/dnceng|dnceng\.pkgs\.visualstudio\.com)\/public\/_packaging\//i,
+ /(timed out|failed to connect|could not resolve|ENOTFOUND|ECONNRESET|EPROTO|Bad Gateway|SSL connection could not be established).{0,160}https:\/\/(?:pkgs\.dev\.azure\.com\/dnceng|dnceng\.pkgs\.visualstudio\.com)\/public\/_packaging\//i,
+ /https:\/\/(?:pkgs\.dev\.azure\.com\/dnceng|dnceng\.pkgs\.visualstudio\.com)\/public\/_packaging\/.{0,160}(timed out|failed to connect|could not resolve|ENOTFOUND|ECONNRESET|EPROTO|Bad Gateway|SSL connection could not be established)/i,
+ /(timed out|failed to connect|could not resolve|ENOTFOUND|ECONNRESET|EPROTO|Bad Gateway|SSL connection could not be established).{0,160}builds\.dotnet\.microsoft\.com/i,
+ /builds\.dotnet\.microsoft\.com.{0,160}(timed out|failed to connect|could not resolve|ENOTFOUND|ECONNRESET|EPROTO|Bad Gateway|SSL connection could not be established)/i,
+ /(timed out|failed to connect|failed to respond|could not resolve|ENOTFOUND|ECONNRESET|EPROTO|Bad Gateway|SSL connection could not be established).{0,160}api\.github\.com/i,
+ /api\.github\.com.{0,160}(timed out|failed to connect|failed to respond|could not resolve|ENOTFOUND|ECONNRESET|EPROTO|Bad Gateway|SSL connection could not be established)/i,
+ /fatal: unable to access 'https:\/\/github\.com\/.*': The requested URL returned error:\s*(502|503|504)/i,
+ /Failed to connect to github\.com port/i,
+ /expected 'packfile'/i,
+ /\bRPC failed\b/i,
+ /\bRecv failure\b/i,
];
function matchesAny(value, patterns) {
return patterns.some(pattern => pattern.test(value));
}
+function findMatchingPattern(value, patterns) {
+ return patterns.find(pattern => pattern.test(value)) ?? null;
+}
+
function parseCheckRunId(checkRunUrl) {
if (typeof checkRunUrl !== 'string') {
return null;
@@ -154,16 +163,81 @@ function getFailureStepSignals(failedSteps) {
};
}
+function canUseInfrastructureNetworkLogOverride(failedSteps) {
+ return failedSteps.length > 0 && !failedSteps.some(step => matchesAny(step, testExecutionFailureStepPatterns));
+}
+
+function formatFailedStepLabel(failedSteps, failedStepText) {
+ const label = failedSteps.length === 1 ? 'Failed step' : 'Failed steps';
+ return `${label} '${failedStepText}'`;
+}
+
+function isSingleFailedStep(failedSteps) {
+ return failedSteps.length === 1;
+}
+
+function getInfrastructureNetworkLogOverrideReason(failedStepText, matchedPattern) {
+ const patternText = matchedPattern ? ` Matched pattern: ${matchedPattern}.` : '';
+ return `Failed step '${failedStepText}' will be retried because the job log shows a likely transient infrastructure network failure.${patternText}`;
+}
+
+function getOutsideRetryRulesReason(failedSteps, failedStepText) {
+ return `${formatFailedStepLabel(failedSteps, failedStepText)} ${isSingleFailedStep(failedSteps) ? 'is' : 'are'} not covered by the retry-safe rerun rules.`;
+}
+
+function getNoRetryMatchReason({
+ failedSteps,
+ failedStepText,
+ hasRetryableStep,
+ hasIgnoredFailureStep,
+ hasTestExecutionFailureStep,
+ annotationsText,
+}) {
+ const failedStepLabel = formatFailedStepLabel(failedSteps, failedStepText);
+
+ if (hasTestExecutionFailureStep) {
+ return `${failedStepLabel} ${isSingleFailedStep(failedSteps) ? 'includes' : 'include'} a test execution failure, so the job was not retried without a high-confidence infrastructure override.`;
+ }
+
+ if (hasIgnoredFailureStep) {
+ return `${failedStepLabel} ${isSingleFailedStep(failedSteps) ? 'is' : 'are'} only retried when the job shows a high-confidence infrastructure override, and none was found.`;
+ }
+
+ if (hasRetryableStep) {
+ return `${failedStepLabel} did not include a retry-safe transient infrastructure signal in the job annotations.`;
+ }
+
+ if (annotationsText) {
+ return 'The job annotations did not show a retry-safe transient infrastructure failure.';
+ }
+
+ return 'No retry-safe transient infrastructure signal was found in the job annotations or logs.';
+}
+
function classifyFailedJob(job, annotationsOrText, jobLogText = '') {
const failedSteps = getFailedSteps(job);
const failedStepText = failedSteps.join(' | ');
const { hasRetryableStep, hasIgnoredFailureStep, shouldInspectAnnotations } = getFailureStepSignals(failedSteps);
+ const hasTestExecutionFailureStep = failedSteps.some(step => matchesAny(step, testExecutionFailureStepPatterns));
+ const matchedInfrastructureNetworkLogOverridePattern =
+ !hasTestExecutionFailureStep
+ ? findMatchingPattern(jobLogText, infrastructureNetworkFailureLogOverridePatterns)
+ : null;
+ const matchesInfrastructureNetworkLogOverride = matchedInfrastructureNetworkLogOverridePattern !== null;
if (!shouldInspectAnnotations) {
+ if (matchesInfrastructureNetworkLogOverride) {
+ return {
+ retryable: true,
+ failedSteps,
+ reason: getInfrastructureNetworkLogOverrideReason(failedStepText, matchedInfrastructureNetworkLogOverridePattern),
+ };
+ }
+
return {
retryable: false,
failedSteps,
- reason: 'Failed steps are outside the retry-safe allowlist.',
+ reason: getOutsideRetryRulesReason(failedSteps, failedStepText),
};
}
@@ -206,21 +280,25 @@ function classifyFailedJob(job, annotationsOrText, jobLogText = '') {
};
}
- const hasIgnoredBuildFailureStep = failedSteps.some(step => matchesAny(step, feedNetworkFailureStepPatterns));
- if (hasIgnoredBuildFailureStep && matchesAny(jobLogText, ignoredBuildFailureLogOverridePatterns)) {
+ if (matchesInfrastructureNetworkLogOverride) {
return {
retryable: true,
failedSteps,
- reason: `Ignored failed step '${failedStepText}' matched the feed network failure override allowlist.`,
+ reason: getInfrastructureNetworkLogOverrideReason(failedStepText, matchedInfrastructureNetworkLogOverridePattern),
};
}
return {
retryable: false,
failedSteps,
- reason: annotationsText
- ? 'Annotations did not match the transient allowlist.'
- : 'No retry-safe step or annotation signature matched.',
+ reason: getNoRetryMatchReason({
+ failedSteps,
+ failedStepText,
+ hasRetryableStep,
+ hasIgnoredFailureStep,
+ hasTestExecutionFailureStep,
+ annotationsText,
+ }),
};
}
@@ -243,7 +321,7 @@ async function analyzeFailedJobs({ jobs, getAnnotationsForJob, getJobLogTextForJ
const shouldInspectLogs =
!classification.retryable &&
getJobLogTextForJob &&
- failedSteps.some(step => matchesAny(step, feedNetworkFailureStepPatterns));
+ canUseInfrastructureNetworkLogOverride(failedSteps);
if (shouldInspectLogs) {
classification = classifyFailedJob(
@@ -276,6 +354,47 @@ function computeRerunEligibility({ dryRun, retryableCount, maxRetryableJobs = de
return !dryRun && retryableCount > 0 && retryableCount <= maxRetryableJobs;
}
+function buildSummaryReference(url, text) {
+ return { url, text };
+}
+
+function addSummaryReference(summary, label, reference) {
+ summary.addRaw(`${label}: `);
+
+ if (reference?.url) {
+ summary.addLink(reference.text, reference.url);
+ }
+ else {
+ summary.addRaw(reference?.text || 'not available');
+ }
+
+ return summary.addBreak();
+}
+
+function addSummaryCommentReferences(summary, postedComments) {
+ if (!postedComments?.length) {
+ summary.addRaw('Pull request comments: none posted').addBreak();
+ return summary;
+ }
+
+ summary.addRaw('Pull request comments:').addBreak();
+
+ for (const comment of postedComments) {
+ summary.addRaw('- ');
+
+ if (comment.htmlUrl) {
+ summary.addLink(`PR #${comment.pullRequestNumber} comment`, comment.htmlUrl);
+ }
+ else {
+ summary.addRaw(`PR #${comment.pullRequestNumber} comment`);
+ }
+
+ summary.addBreak();
+ }
+
+ return summary;
+}
+
async function writeAnalysisSummary({
summary,
failedJobs,
@@ -285,9 +404,27 @@ async function writeAnalysisSummary({
dryRun,
rerunEligible,
sourceRunUrl,
+ sourceRunAttempt,
}) {
+ const analyzedRunReference = buildSummaryReference(
+ buildWorkflowRunAttemptUrl(sourceRunUrl, sourceRunAttempt),
+ Number.isInteger(sourceRunAttempt) && sourceRunAttempt > 0
+ ? `workflow run attempt ${sourceRunAttempt}`
+ : 'workflow run'
+ );
+ const outcome = rerunEligible ? 'Rerun eligible' : 'Rerun skipped';
+ const outcomeDetails = rerunEligible
+ ? `Matched ${retryableJobs.length} retry-safe job${retryableJobs.length === 1 ? '' : 's'} for rerun.`
+ : retryableJobs.length === 0
+ ? 'No retry-safe jobs were found in the analyzed run.'
+ : dryRun
+ ? 'Dry run is enabled, so no rerun requests will be sent.'
+ : retryableJobs.length > maxRetryableJobs
+ ? `Matched ${retryableJobs.length} jobs, which exceeds the cap of ${maxRetryableJobs}.`
+ : 'The analyzed run did not satisfy the workflow safety rails for reruns.';
const summaryRows = [
[{ data: 'Category', header: true }, { data: 'Count', header: true }],
+ ['Outcome', outcome],
['Failed jobs inspected', String(failedJobs.length)],
['Retryable jobs', String(retryableJobs.length)],
['Skipped jobs', String(skippedJobs.length)],
@@ -295,14 +432,15 @@ async function writeAnalysisSummary({
['Dry run', String(dryRun)],
['Eligible to rerun', String(rerunEligible)],
];
- const sourceRunReference = sourceRunUrl
- ? `[workflow run](${sourceRunUrl})`
- : 'workflow run';
await summary
- .addHeading('Transient CI rerun analysis')
- .addTable(summaryRows)
- .addRaw(`Source run: ${sourceRunReference}\n\n`);
+ .addHeading(outcome)
+ .addTable(summaryRows);
+
+ addSummaryReference(summary, 'Analyzed run', analyzedRunReference)
+ .addRaw(outcomeDetails)
+ .addBreak()
+ .addBreak();
if (retryableJobs.length > 0) {
await summary.addHeading('Retryable jobs', 2);
@@ -320,12 +458,6 @@ async function writeAnalysisSummary({
]);
}
- if (retryableJobs.length > maxRetryableJobs) {
- await summary
- .addHeading('Automatic rerun skipped', 2)
- .addRaw(`Matched ${retryableJobs.length} jobs, which exceeds the cap of ${maxRetryableJobs}.`, true);
- }
-
await summary.write();
}
@@ -361,20 +493,17 @@ function buildWorkflowRunAttemptUrl(sourceRunUrl, runAttempt) {
return `${sourceRunUrl.replace(/\/$/, '')}/attempts/${runAttempt}`;
}
-function buildWorkflowRunReferenceText(sourceRunUrl, runAttempt) {
- const workflowRunUrl = buildWorkflowRunAttemptUrl(sourceRunUrl, runAttempt);
-
- if (!workflowRunUrl) {
- return Number.isInteger(runAttempt) && runAttempt > 0
+function buildWorkflowRunReference(sourceRunUrl, runAttempt) {
+ return buildSummaryReference(
+ buildWorkflowRunAttemptUrl(sourceRunUrl, runAttempt),
+ Number.isInteger(runAttempt) && runAttempt > 0
? `workflow run attempt ${runAttempt}`
- : 'workflow run';
- }
-
- const label = Number.isInteger(runAttempt) && runAttempt > 0
- ? `workflow run attempt ${runAttempt}`
- : 'workflow run';
+ : 'workflow run'
+ );
+}
- return `[${label}](${workflowRunUrl})`;
+function formatMarkdownLink(text, url) {
+ return url ? `[${text}](${url})` : text;
}
async function getLatestRunAttempt({ github, owner, repo, runId }) {
@@ -403,8 +532,8 @@ function buildPullRequestCommentBody({
retryableJobs,
}) {
return [
- `The transient CI rerun workflow requested reruns for the following jobs after analyzing [the failed attempt](${failedAttemptUrl}).`,
- `GitHub's job rerun API also reruns dependent jobs, so the retry is being tracked in [the rerun attempt](${rerunAttemptUrl}).`,
+ `The transient CI rerun workflow requested reruns for the following jobs after analyzing ${formatMarkdownLink('the failed attempt', failedAttemptUrl)}.`,
+ `GitHub's job rerun API also reruns dependent jobs, so the retry is being tracked in ${formatMarkdownLink('the rerun attempt', rerunAttemptUrl)}.`,
'The job links below point to the failed attempt that matched the retry-safe transient failure rules.',
'',
...retryableJobs.map(job => {
@@ -418,14 +547,23 @@ function buildPullRequestCommentBody({
}
async function addPullRequestComments({ github, owner, repo, pullRequestNumbers, body }) {
+ const postedComments = [];
+
for (const pullRequestNumber of pullRequestNumbers) {
- await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
+ const response = await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', {
owner,
repo,
issue_number: pullRequestNumber,
body,
});
+
+ postedComments.push({
+ pullRequestNumber,
+ htmlUrl: response.data?.html_url || null,
+ });
}
+
+ return postedComments;
}
async function rerunMatchedJobs({
@@ -451,9 +589,21 @@ async function rerunMatchedJobs({
});
if (pullRequestNumbers.length > 0 && openPullRequestNumbers.length === 0) {
+ const failedAttemptReference = buildWorkflowRunReference(sourceRunUrl, sourceRunAttempt);
await summary
- .addHeading('Automatic rerun skipped')
- .addRaw('All associated pull requests are closed. No jobs were rerun.', true)
+ .addHeading('Rerun skipped');
+
+ addSummaryReference(summary, 'Analyzed run', failedAttemptReference)
+ .addRaw('All associated pull requests are closed. No jobs were rerun.')
+ .addBreak()
+ .addBreak();
+
+ await summary
+ .addHeading('Retryable jobs', 2)
+ .addTable([
+ [{ data: 'Job', header: true }, { data: 'Reason', header: true }],
+ ...retryableJobs.map(job => [job.name, job.reason]),
+ ])
.write();
return;
}
@@ -480,41 +630,37 @@ async function rerunMatchedJobs({
? latestRunAttempt
: normalizedSourceRunAttempt ? normalizedSourceRunAttempt + 1 : null;
const rerunAttemptUrl = buildWorkflowRunAttemptUrl(sourceRunUrl, rerunAttemptNumber);
- const failedAttemptReference = buildWorkflowRunReferenceText(sourceRunUrl, normalizedSourceRunAttempt);
- const rerunAttemptReference = buildWorkflowRunReferenceText(sourceRunUrl, rerunAttemptNumber);
+ const failedAttemptReference = buildWorkflowRunReference(sourceRunUrl, normalizedSourceRunAttempt);
+ const rerunAttemptReference = buildWorkflowRunReference(sourceRunUrl, rerunAttemptNumber);
+ let postedComments = [];
if (openPullRequestNumbers.length > 0) {
- await addPullRequestComments({
+ postedComments = await addPullRequestComments({
github,
owner,
repo,
pullRequestNumbers: openPullRequestNumbers,
body: buildPullRequestCommentBody({
- failedAttemptUrl,
- rerunAttemptUrl,
+ failedAttemptUrl: failedAttemptReference.url,
+ rerunAttemptUrl: rerunAttemptReference.url,
retryableJobs,
}),
});
}
- const commentedPullRequestsText = openPullRequestNumbers.length > 0
- ? openPullRequestNumbers.map(number => `#${number}`).join(', ')
- : null;
-
const summaryBuilder = summary
- .addHeading('Rerun requested')
- .addRaw(`Failed attempt: ${failedAttemptReference}\nRerun attempt: ${rerunAttemptReference}\n\n`)
+ .addHeading('Rerun requested');
+
+ addSummaryReference(summaryBuilder, 'Failed attempt', failedAttemptReference);
+ addSummaryReference(summaryBuilder, 'Rerun attempt', rerunAttemptReference);
+ addSummaryCommentReferences(summaryBuilder, postedComments).addBreak();
+
+ summaryBuilder
.addTable([
[{ data: 'Job', header: true }, { data: 'Reason', header: true }],
...retryableJobs.map(job => [job.name, job.reason]),
]);
- if (commentedPullRequestsText) {
- summaryBuilder
- .addHeading('Pull request comments', 2)
- .addRaw(`Posted rerun details to ${commentedPullRequestsText}.`, true);
- }
-
await summaryBuilder.write();
}
diff --git a/.github/workflows/auto-rerun-transient-ci-failures.yml b/.github/workflows/auto-rerun-transient-ci-failures.yml
index 2613e71eb13..4f7b368b6e5 100644
--- a/.github/workflows/auto-rerun-transient-ci-failures.yml
+++ b/.github/workflows/auto-rerun-transient-ci-failures.yml
@@ -15,6 +15,11 @@ on:
description: 'CI workflow run ID to inspect'
required: true
type: number
+ dry_run:
+ description: 'Inspect and summarize without requesting reruns'
+ required: false
+ default: false
+ type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.run_id || github.event.workflow_run.id }}
@@ -57,6 +62,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
MANUAL_RUN_ID: ${{ inputs.run_id }}
+ MANUAL_DRY_RUN: ${{ inputs.dry_run }}
with:
script: |
const rerunWorkflow = require('./.github/workflows/auto-rerun-transient-ci-failures.js');
@@ -103,6 +109,14 @@ jobs:
return response.data;
}
+ function parseManualDryRun() {
+ if (!isWorkflowDispatch) {
+ return false;
+ }
+
+ return String(process.env.MANUAL_DRY_RUN).toLowerCase() === 'true';
+ }
+
async function listJobsForAttempt(runId, attemptNumber) {
return paginate(
'GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}/jobs',
@@ -173,7 +187,7 @@ jobs:
}
const workflowRun = await getWorkflowRun();
- const dryRun = isWorkflowDispatch;
+ const dryRun = parseManualDryRun();
const sourceRunUrl = workflowRun.html_url || `https://github.com/${owner}/${repo}/actions/runs/${workflowRun.id}`;
core.setOutput('source_run_id', String(workflowRun.id));
@@ -236,6 +250,7 @@ jobs:
dryRun,
rerunEligible,
sourceRunUrl,
+ sourceRunAttempt: runAttempt,
});
if (retryableJobs.length === 0) {
@@ -243,11 +258,6 @@ jobs:
return;
}
- if (dryRun) {
- console.log('workflow_dispatch runs in dry-run mode. No jobs will be rerun.');
- return;
- }
-
rerun-transient-failures:
name: Rerun transient CI failures
needs: [analyze-transient-failures]
diff --git a/Aspire.slnx b/Aspire.slnx
index 03ee4cce7bd..d341be34c8d 100644
--- a/Aspire.slnx
+++ b/Aspire.slnx
@@ -48,6 +48,7 @@
+
@@ -383,6 +384,7 @@
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 58f97e5297d..af7835183e6 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -211,6 +211,7 @@
+
diff --git a/docs/ci/auto-rerun-transient-ci-failures.md b/docs/ci/auto-rerun-transient-ci-failures.md
index 38bfb0aecdf..a6329136dd8 100644
--- a/docs/ci/auto-rerun-transient-ci-failures.md
+++ b/docs/ci/auto-rerun-transient-ci-failures.md
@@ -12,7 +12,7 @@ It is intentionally conservative:
- it does not rerun every failed job in a run
- it treats mixed deterministic failures plus transient post-step noise as non-retryable by default
-- it keeps `workflow_dispatch` in dry-run mode for historical inspection and matcher tuning
+- it keeps `workflow_dispatch` behind the same matcher and safety rails as automatic execution, with an optional dry-run mode for inspection-only runs
## Matcher behavior
@@ -23,16 +23,18 @@ It is intentionally conservative:
- Keep the mixed-failure veto: if an ignored step such as `Run tests*` failed, do not rerun the job based only on unrelated transient post-step noise.
- Allow a narrow override when an ignored failed step is paired with a high-confidence job-level infrastructure annotation such as runner loss or action-download failure.
- Allow a narrow override for Windows jobs whose failures are limited to post-test cleanup or upload steps when the annotations report process initialization failure `-1073741502` (`0xC0000142`).
-- Allow a narrow log-based override for supported CI SDK bootstrap, build, package, and validation steps when the job log shows `Unable to load the service index` against the approved `dnceng` public feeds.
+- Allow a narrow log-based override for non-test-execution failures when the job log shows high-confidence infrastructure network failures against approved `dnceng` public feeds, `builds.dotnet.microsoft.com`, `api.github.com`, or `github.com`.
## Safety rails
-- `workflow_dispatch` remains dry-run only. It exists for historical inspection and matcher tuning, not for issuing reruns.
+- `workflow_dispatch` can inspect any `CI` workflow run by ID and request reruns when the same retry-safety rules are satisfied.
+- `workflow_dispatch` also exposes an optional `dry_run` input so manual runs can produce the analysis summary without sending rerun requests.
- Automatic rerun requires at least one retryable job.
- Automatic rerun is suppressed when matched jobs exceed the configured cap.
- Before issuing reruns, the workflow confirms that at least one associated pull request is still open.
- The workflow targets only the matched jobs when issuing rerun requests rather than rerunning the entire source run, although GitHub's job-rerun API also reruns dependent jobs automatically.
-- The workflow summary links to the analyzed workflow run and, when reruns are requested, to both the failed attempt and the rerun attempt.
+- The workflow summary clearly states whether reruns were skipped, are eligible, or were requested, and links to the analyzed workflow run.
+- When reruns are requested, the rerun summary also links to both the failed attempt and the rerun attempt, plus any posted pull request comments.
- After successful rerun requests, the workflow comments on the open associated pull request with links to the failed attempt, the rerun attempt, per-job failed-attempt links, and retry reasons.
## Tests
@@ -44,4 +46,4 @@ Those tests are intentionally behavior-focused rather than regex-focused:
- they use representative fixtures for each supported behavior
- they keep representative job and step fixtures anchored to the current CI workflow names so matcher coverage does not drift from the implementation
- they cover the mixed-failure veto and ignored-step override explicitly
-- they keep only a minimal set of YAML contract checks for safety rails such as `workflow_dispatch` dry-run mode, first-attempt-only automatic reruns, and gating the rerun job on `rerun_eligible`
+- they keep only a minimal set of YAML contract checks for safety rails such as first-attempt-only automatic reruns, the optional manual `dry_run` override, enabling manual reruns through `workflow_dispatch`, and gating the rerun job on `rerun_eligible`
diff --git a/eng/Versions.props b/eng/Versions.props
index 77366ba7476..0b7846af180 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -93,6 +93,7 @@
10.0.3
10.0.3
10.0.3
+ 10.0.3
10.0.3
diff --git a/eng/pipelines/azure-pipelines-unofficial.yml b/eng/pipelines/azure-pipelines-unofficial.yml
index ef0ca001eb1..c58d13a6a86 100644
--- a/eng/pipelines/azure-pipelines-unofficial.yml
+++ b/eng/pipelines/azure-pipelines-unofficial.yml
@@ -39,13 +39,15 @@ resources:
extends:
template: v1/1ES.Unofficial.PipelineTemplate.yml@1ESPipelineTemplates
parameters:
+ settings:
+ networkIsolationPolicy: Permissive,CFSClean2
featureFlags:
autoEnablePREfastWithNewRuleset: false
autoEnableRoslynWithNewRuleset: false
sdl:
sourceAnalysisPool:
name: NetCore1ESPool-Internal
- image: windows.vs2022preview.amd64
+ image: windows.vs2026preview.scout.amd64
os: windows
containers:
linux_x64:
@@ -135,7 +137,7 @@ extends:
pool:
name: NetCore1ESPool-Internal
- image: windows.vs2022preview.amd64
+ image: windows.vs2026preview.scout.amd64
os: windows
variables:
@@ -162,6 +164,40 @@ extends:
script: |
Get-ChildItem -Path "$(Build.SourcesDirectory)\artifacts\packages" -File -Recurse | Select-Object FullName, @{Name="Size(MB)";Expression={[math]::Round($_.Length/1MB,2)}} | Format-Table -AutoSize
+ - task: NodeTool@0
+ displayName: 🟣Install node.js
+ inputs:
+ versionSpec: '20.x'
+
+ - task: npmAuthenticate@0
+ displayName: 🟣NPM authenticate
+ inputs:
+ workingFile: $(Build.SourcesDirectory)\.npmrc
+
+ - task: PowerShell@2
+ displayName: 🟣Set .npmrc environment
+ inputs:
+ targetType: 'inline'
+ script: Write-Host "##vso[task.setvariable variable=NPM_CONFIG_USERCONFIG]$(Build.SourcesDirectory)\.npmrc"
+
+ - task: PowerShell@2
+ displayName: 🟣Install yarn
+ inputs:
+ targetType: 'inline'
+ script: |
+ npm install -g yarn@1.22.22
+ yarn --version
+ workingDirectory: '$(Build.SourcesDirectory)'
+
+ - task: PowerShell@2
+ displayName: 🟣Install vsce
+ inputs:
+ targetType: 'inline'
+ script: |
+ npm install -g @vscode/vsce@3.7.1
+ vsce --version
+ workingDirectory: '$(Build.SourcesDirectory)'
+
- template: /eng/pipelines/templates/BuildAndTest.yml
parameters:
dotnetScript: $(Build.SourcesDirectory)/dotnet.cmd
diff --git a/eng/pipelines/azure-pipelines.yml b/eng/pipelines/azure-pipelines.yml
index 75d22b26e68..6512082521e 100644
--- a/eng/pipelines/azure-pipelines.yml
+++ b/eng/pipelines/azure-pipelines.yml
@@ -105,6 +105,8 @@ resources:
extends:
template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates
parameters:
+ settings:
+ networkIsolationPolicy: Permissive,CFSClean2
featureFlags:
autoEnablePREfastWithNewRuleset: false
autoEnableRoslynWithNewRuleset: false
diff --git a/extension/README.md b/extension/README.md
index 0f2f490c3c3..21444ab588e 100644
--- a/extension/README.md
+++ b/extension/README.md
@@ -1,39 +1,72 @@
+# Aspire for Visual Studio Code
-# Aspire VS Code Extension
+The official Aspire extension for VS Code. Run, debug, and deploy your Aspire apps without leaving the editor.
-The Aspire VS Code extension provides a set of commands and tools to help you work with Aspire and Aspire AppHost projects directly from Visual Studio Code.
+Aspire helps you build distributed apps — things like microservices, databases, containers, and frontends — and wire them together in code. This extension lets you do all of that from VS Code, with debugging support for **C#, Python, Node.js**, and more.
-## Commands
+---
-The extension adds the following commands to VS Code:
+## Table of Contents
-| Command | Description |
+- [Features at a Glance](#features-at-a-glance)
+- [Prerequisites](#prerequisites)
+- [Getting Started](#getting-started)
+- [Running and Debugging](#running-and-debugging)
+- [The Aspire Sidebar](#the-aspire-sidebar)
+- [The Aspire Dashboard](#the-aspire-dashboard)
+- [Commands](#commands)
+- [Language and Debugger Support](#language-and-debugger-support)
+- [Extension Settings](#extension-settings)
+- [MCP Server Integration](#mcp-server-integration)
+- [Feedback and Issues](#feedback-and-issues)
+- [License](#license)
+
+---
+
+## Features at a Glance
+
+| Feature | Description |
|---------|-------------|
-| Aspire: New Aspire project | Create a new Aspire apphost or starter app from a template. |
-| Aspire: Initialize Aspire | Initialize Aspire in an existing project. |
-| Aspire: Add an integration | Add a hosting integration (`Aspire.Hosting.*`) to the Aspire apphost. |
-| Aspire: Update integrations | Update hosting integrations and Aspire SDK in the apphost. |
-| Aspire: Publish deployment artifacts | Generate deployment artifacts for an Aspire apphost. |
-| Aspire: Deploy app | Deploy the contents of an Aspire apphost to its defined deployment targets. |
-| Aspire: Execute pipeline step (aspire do) | Execute a specific pipeline step and its dependencies. |
-| Aspire: Configure launch.json file | Add the default Aspire debugger launch configuration to your workspace's `launch.json`. |
-| Aspire: Extension settings | Open Aspire extension settings. |
-| Aspire: Open local Aspire settings | Open the local `.aspire/settings.json` file for the current workspace. |
-| Aspire: Open global Aspire settings | Open the global `~/.aspire/globalsettings.json` file. |
-| Aspire: Open Aspire terminal | Open an Aspire VS Code terminal for working with Aspire projects. |
+| **Run & debug** | Start your whole app and attach debuggers to every service with F5 |
+| **Dashboard** | See your resources, endpoints, logs, traces, and metrics while your app runs |
+| **Sidebar** | Browse running apphosts and resources in the Activity Bar |
+| **Integrations** | Add databases, queues, and cloud services from the Command Palette |
+| **Scaffolding** | Create new Aspire projects from templates |
+| **Deploy** | Generate deployment artifacts or push to cloud targets |
+| **MCP** | Let AI tools like GitHub Copilot see your running app via the Model Context Protocol |
+| **Multi-language** | Debug C#, Python, Node.js, Azure Functions, and browser apps together |
-All commands are available from the Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`) and are grouped under the "Aspire" category.
+---
-## Debugging
+## Prerequisites
+
+### Aspire CLI
+
+The [Aspire CLI](https://aspire.dev/get-started/install-cli/) needs to be installed and on your PATH. You can install it directly from VS Code with the **Aspire: Install Aspire CLI (stable)** command, or follow the [installation guide](https://aspire.dev/get-started/install-cli/) for manual setup.
+
+### .NET
-To run an Aspire application using the Aspire VS Code extension, you must be using Aspire 9.5 or higher. Some features are only available when certain VS Code extensions are installed and available. See the feature matrix below:
+[.NET 10 or later](https://dotnet.microsoft.com/download) is required.
-| Feature | Requirement | Notes |
-|---------|-------------|-------|
-| Debug C# projects | [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) or [C# for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp) | The C# extension is required for debugging .NET projects. Apphosts will be built in VS Code if C# Dev Kit is available. |
-| Debug Python projects | [Python extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python) | Required for debugging Python projects |
+### VS Code
-To run and debug your Aspire application, add an entry to the workspace `launch.json`. You can change the apphost to run by setting the `program` field to an apphost project file based on the below example:
+VS Code 1.98 or later is required.
+
+---
+
+## Getting Started
+
+Open your Aspire project in VS Code, or create one with **Aspire: New Aspire project** from the Command Palette. Run **Aspire: Configure launch.json file** to set up the debug configuration, then press **F5**. The extension will build your apphost, start your services, attach debuggers, and open the dashboard.
+
+There's also a built-in walkthrough at **Help → Get Started → Get started with Aspire** that covers the basics step by step.
+
+---
+
+## Running and Debugging
+
+### Launch configuration
+
+Add an entry to `.vscode/launch.json` pointing at your apphost project:
```json
{
@@ -44,7 +77,17 @@ To run and debug your Aspire application, add an entry to the workspace `launch.
}
```
-You can also use the `command` property to run deploy, publish, or pipeline step commands with the debugger attached:
+When you hit **F5**, the extension builds the apphost, starts all the resources (services, containers, databases) in the right order, hooks up debuggers based on each service's language, and opens the dashboard.
+
+You can also right-click an `apphost.cs`, `apphost.ts`, or `apphost.js` file in the Explorer and pick **Run Aspire apphost** or **Debug Aspire apphost**.
+
+### Deploy, publish, and pipeline steps
+
+The `command` property in the launch config lets you do more than just run:
+
+- **`deploy`** — push to your defined deployment targets.
+- **`publish`** — generate deployment artifacts (manifests, Bicep files, etc.).
+- **`do`** — run a specific pipeline step. Set `step` to the step name.
```json
{
@@ -56,75 +99,129 @@ You can also use the `command` property to run deploy, publish, or pipeline step
}
```
-Supported values for `command` are `run` (default), `deploy`, `publish`, and `do`. When using `do`, you can optionally set the `step` property to specify the pipeline step to execute:
+### Customizing debugger settings per language
+
+The `debuggers` property lets you pass debug config specific to a language. Use `project` for C#/.NET services, `python` for Python, and `apphost` for the apphost itself:
```json
{
"type": "aspire",
"request": "launch",
- "name": "Aspire: Run pipeline step",
+ "name": "Aspire: Launch MyAppHost",
"program": "${workspaceFolder}/MyAppHost/MyAppHost.csproj",
- "command": "do",
- "step": "my-custom-step"
+ "debuggers": {
+ "project": {
+ "console": "integratedTerminal",
+ "logging": { "moduleLoad": false }
+ },
+ "apphost": {
+ "stopAtEntry": true
+ }
+ }
}
```
-## Requirements
+---
-### Aspire CLI
+## The Aspire Sidebar
-The [Aspire CLI](https://aspire.dev/get-started/install-cli/) must be installed and available on the path. You can install using the following scripts.
+The extension adds an **Aspire** panel to the Activity Bar. It shows a live tree of your resources. In **Workspace** mode you see resources from the apphost in your current workspace, updating in real time. Switch to **Global** mode with the toggle in the panel header to see every running apphost on your machine.
-On Windows:
+Right-click a resource to start, stop, or restart it, view its logs, run resource-specific commands, or open the dashboard.
-```powershell
-irm https://aspire.dev/install.ps1 | iex
-```
+---
-On Linux or macOS:
+## The Aspire Dashboard
-```sh
-curl -sSL https://aspire.dev/install.sh | bash
-```
+The dashboard gives you a live view of your running app — all your resources and their health, endpoint URLs, console logs from every service, structured logs (via OpenTelemetry), distributed traces across services, and metrics.
-### .NET
+
-[.NET 8+](https://dotnet.microsoft.com/en-us/download) must be installed.
+It opens automatically when you start your app. You can pick which browser it uses with the `aspire.dashboardBrowser` setting — system default browser, or Chrome, Edge, or Firefox as a debug session. When using a debug browser, the `aspire.closeDashboardOnDebugEnd` setting controls whether it closes automatically when you stop debugging. Firefox also requires the [Firefox Debugger](https://marketplace.visualstudio.com/items?itemName=firefox-devtools.vscode-firefox-debug) extension.
+
+---
+
+## Commands
+
+All commands live in the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`) under **Aspire**.
+
+| Command | Description |
+|---------|-------------|
+| **New Aspire project** | Create a new apphost or starter app from a template |
+| **Initialize Aspire in an existing codebase** | Add Aspire to an existing project |
+| **Add an integration** | Add a hosting integration (`Aspire.Hosting.*`) |
+| **Update integrations** | Update hosting integrations and the Aspire SDK |
+| **Publish deployment artifacts** | Generate deployment manifests |
+| **Deploy app** | Deploy to your defined targets |
+| **Execute pipeline step** | Run a pipeline step and its dependencies (`aspire do`) |
+| **Configure launch.json file** | Add the Aspire debug config to your workspace |
+| **Extension settings** | Open Aspire settings |
+| **Open local Aspire settings** | Open `.aspire/settings.json` for this workspace |
+| **Open global Aspire settings** | Open `~/.aspire/globalsettings.json` |
+| **Open Aspire terminal** | Open a terminal with the Aspire CLI ready |
+| **Install Aspire CLI (stable)** | Install the latest stable CLI |
+| **Install Aspire CLI (daily)** | Install the daily preview build |
+| **Update Aspire CLI** | Update the CLI |
+| **Verify Aspire CLI installation** | Check that the CLI works |
+
+---
+
+## Language and Debugger Support
+
+The extension figures out what language each resource uses and attaches the right debugger. Some languages need a companion extension:
+
+| Language | Debugger | Extension needed |
+|----------|----------|------------------|
+| C# / .NET | coreclr | [C# Dev Kit](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csdevkit) or [C#](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp) |
+| Python | debugpy | [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) |
+| Node.js | js-debug (built-in) | None |
+| Browser apps | js-debug (built-in) | None |
+| Azure Functions | varies by language | [Azure Functions](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azurefunctions) + language extension |
+
+Node.js and browser debugging just work — VS Code has a built-in JavaScript debugger. C# Dev Kit gives you richer build integration than the standalone C# extension, but either one works for debugging. Azure Functions debugging supports C#, JavaScript/TypeScript, and Python.
+
+---
+
+## Extension Settings
+
+You can configure the extension under **Settings → Aspire**, or jump there with **Aspire: Extension settings**. The most commonly used:
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `aspire.aspireCliExecutablePath` | `""` | Path to the Aspire CLI. Leave empty to use `aspire` from PATH. |
+| `aspire.dashboardBrowser` | `openExternalBrowser` | Which browser to open the dashboard in — system default, or Chrome/Edge/Firefox as a debug session |
+| `aspire.enableAspireDashboardAutoLaunch` | `launch` | Controls what happens with the dashboard when debugging starts: `launch` (auto-open), `notification` (show link), or `off` |
+| `aspire.registerMcpServerInWorkspace` | `false` | Register the Aspire MCP server (see [below](#mcp-server-integration)) |
+
+There are more settings for things like verbose logging, startup prompts, and polling intervals — run **Aspire: Extension settings** from the Command Palette to see them all.
+
+The extension also gives you IntelliSense and validation when editing `.aspire/settings.json` (workspace-level config) and `~/.aspire/globalsettings.json` (user-level config). Use the **Open local/global Aspire settings** commands to open them.
+
+---
+
+## MCP Server Integration
+
+The extension can register an Aspire [MCP](https://modelcontextprotocol.io/) server with VS Code. This lets AI tools — GitHub Copilot included — see your running app's resources, endpoints, and configuration, so they have better context when helping you write code or answer questions.
+
+Turn it on by setting `aspire.registerMcpServerInWorkspace` to `true`. When enabled, the extension registers the MCP server definition via the Aspire CLI whenever a workspace is open and the CLI is available.
+
+---
## Feedback and Issues
-Please report [issues](https://github.com/dotnet/aspire/issues/new?template=10_bug_report.yml&labels=area-extension) or [feature requests](https://github.com/dotnet/aspire/issues/new?template=20_feature-request.yml&labels=area-extension) on the Aspire [GitHub repository](https://github.com/dotnet/aspire/issues) using the label `area-extension`.
+Found a bug or have an idea? File it on the [dotnet/aspire](https://github.com/dotnet/aspire/issues) repo:
-## Customizing debugger attributes for resources
+- [Report a bug](https://github.com/dotnet/aspire/issues/new?template=10_bug_report.yml&labels=area-extension)
+- [Request a feature](https://github.com/dotnet/aspire/issues/new?template=20_feature-request.yml&labels=area-extension)
-| Language | Debugger entry |
-|----------|-----------------|
-| C# | project |
-| Python | python |
+### Learn more
-The debuggers property stores common debug configuration properties for different types of Aspire services.
-C#-based services have common debugging properties under `project`. Python-based services have their common properties under `python`.
-There is also a special entry for the apphost (`apphost`). For example:
+- [Aspire docs](https://aspire.dev/docs/)
+- [Integration gallery](https://aspire.dev/integrations/gallery/)
+- [Dashboard overview](https://aspire.dev/dashboard/overview/)
+- [Discord](https://discord.com/invite/raNPcaaSj8)
-```json
-{
- "type": "aspire",
- "request": "launch",
- "name": "Aspire: Launch MyAppHost",
- "program": "${workspaceFolder}/MyAppHost/MyAppHost.csproj",
- "debuggers": {
- "project": {
- "console": "integratedTerminal",
- "logging": {
- "moduleLoad": false
- }
- },
- "apphost": {
- "stopAtEntry": true
- }
- }
-}
-```
+---
## License
diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf
index 5ad10af80d2..298109933ac 100644
--- a/extension/loc/xlf/aspire-vscode.xlf
+++ b/extension/loc/xlf/aspire-vscode.xlf
@@ -52,6 +52,9 @@
+
+ Automatically open the dashboard in the browser.
+
Build failed for project {0} with error: {1}.
@@ -82,6 +85,9 @@
Configure launch.json file
+
+ Controls what happens with the Aspire Dashboard when debugging starts.
+
Create a new project
@@ -109,6 +115,9 @@
Dismiss
+
+ Do nothing — the dashboard URL is still printed in the terminal.
+
Do you want to select the default apphost for this workspace?
@@ -124,9 +133,6 @@
Enable apphost discovery on extension activation and prompt to setup .aspire/settings.json.appHostPath if it does not exist in the workspace.
-
- Enable automatic launch of the Aspire Dashboard when using the Aspire debugger.
-
Enable console debug logging for Aspire CLI commands executed by the extension.
@@ -197,7 +203,7 @@
Include environment variables when logging debug session configurations. This can help diagnose environment-related issues but may expose sensitive information in logs.
- Initialize Aspire
+ Initialize Aspire in an existing codebase
Install Aspire CLI (daily)
@@ -338,7 +344,7 @@
Run {0}
- Running apphosts
+ Running AppHosts
Scaffold a new Aspire project from a starter template. The template includes an apphost orchestrator, a sample API, and a web frontend.
[Create new project](command:aspire-vscode.new)
@@ -355,6 +361,9 @@
Select the default apphost to launch when starting an Aspire debug session
+
+ Show a notification with a link to open the dashboard.
+
Show global apphosts
@@ -380,7 +389,7 @@
The Aspire CLI creates, runs, and manages your applications. Install it using the commands in the panel, then verify your installation.
[Verify installation](command:aspire-vscode.verifyCliInstalled)
- The Aspire CLI is not installed or does not support this feature. Install or update the Aspire CLI to get started.
[Update Aspire CLI](command:aspire-vscode.updateSelf)
[Refresh](command:aspire-vscode.refreshRunningAppHosts)
+ The Aspire CLI is not installed or does not support this feature. Install or update the Aspire CLI and restart VS Code to get started.
[Update Aspire CLI](command:aspire-vscode.updateSelf)
[Refresh](command:aspire-vscode.refreshRunningAppHosts)
The Aspire Dashboard shows your resources, endpoints, logs, traces, and metrics — all in one place.
[Open dashboard](command:aspire-vscode.openDashboard)
@@ -388,6 +397,9 @@
The apphost is not compatible. Consider upgrading the apphost or Aspire CLI.
+
+ The apphost process has terminated. To view console output, select the apphost session from the debug console dropdown.
+
The browser to use when auto-launching the Aspire Dashboard.
diff --git a/extension/package.json b/extension/package.json
index 0c1355e59d2..c5a37e9d557 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -470,9 +470,15 @@
"scope": "window"
},
"aspire.enableAspireDashboardAutoLaunch": {
- "type": "boolean",
- "default": true,
+ "type": "string",
+ "enum": ["launch", "notification", "off"],
+ "default": "launch",
"description": "%configuration.aspire.enableAspireDashboardAutoLaunch%",
+ "enumDescriptions": [
+ "%configuration.aspire.enableAspireDashboardAutoLaunch.launch%",
+ "%configuration.aspire.enableAspireDashboardAutoLaunch.notification%",
+ "%configuration.aspire.enableAspireDashboardAutoLaunch.off%"
+ ],
"scope": "window"
},
"aspire.dashboardBrowser": {
diff --git a/extension/package.nls.json b/extension/package.nls.json
index 78cf3480450..7bb56f0f28d 100644
--- a/extension/package.nls.json
+++ b/extension/package.nls.json
@@ -9,7 +9,7 @@
"extension.debug.defaultConfiguration.description": "Launch the effective Aspire apphost in your workspace",
"command.add": "Add an integration",
"command.new": "New Aspire project",
- "command.init": "Initialize Aspire",
+ "command.init": "Initialize Aspire in an existing codebase",
"command.publish": "Publish deployment artifacts",
"command.update": "Update integrations",
"command.updateSelf": "Update Aspire CLI",
@@ -24,7 +24,10 @@
"configuration.aspire.aspireCliExecutablePath": "The path to the Aspire CLI executable. If not set, the extension will attempt to use 'aspire' from the system PATH.",
"configuration.aspire.enableAspireCliDebugLogging": "Enable console debug logging for Aspire CLI commands executed by the extension.",
"configuration.aspire.enableAspireDcpDebugLogging": "Enable Developer Control Plane (DCP) debug logging for Aspire applications. Logs will be stored in the workspace's .aspire/dcp/logs-{debugSessionId} folder.",
- "configuration.aspire.enableAspireDashboardAutoLaunch": "Enable automatic launch of the Aspire Dashboard when using the Aspire debugger.",
+ "configuration.aspire.enableAspireDashboardAutoLaunch": "Controls what happens with the Aspire Dashboard when debugging starts.",
+ "configuration.aspire.enableAspireDashboardAutoLaunch.launch": "Automatically open the dashboard in the browser.",
+ "configuration.aspire.enableAspireDashboardAutoLaunch.notification": "Show a notification with a link to open the dashboard.",
+ "configuration.aspire.enableAspireDashboardAutoLaunch.off": "Do nothing — the dashboard URL is still printed in the terminal.",
"configuration.aspire.dashboardBrowser": "The browser to use when auto-launching the Aspire Dashboard.",
"configuration.aspire.dashboardBrowser.openExternalBrowser": "Use the system default browser (cannot auto-close).",
"configuration.aspire.dashboardBrowser.debugChrome": "Launch Chrome as a debug session (auto-closes when debugging ends).",
@@ -76,6 +79,7 @@
"aspire-vscode.strings.csharpSupportNotEnabled": "C# support is not enabled in this workspace. This project should have started through the Aspire CLI.",
"aspire-vscode.strings.failedToStartProject": "Failed to start project: {0}.",
"aspire-vscode.strings.dcpServerNotInitialized": "DCP server not initialized - cannot forward debug output.",
+ "aspire-vscode.strings.appHostSessionTerminated": "The apphost process has terminated. To view console output, select the apphost session from the debug console dropdown.",
"aspire-vscode.strings.invalidTokenProvided": "Invalid token provided.",
"aspire-vscode.strings.noWorkspaceFolder": "No workspace folder found.",
"aspire-vscode.strings.aspireConfigExists": "Aspire launch configuration already exists in launch.json.",
@@ -112,9 +116,9 @@
"aspire-vscode.strings.selectFileTitle": "Select file",
"aspire-vscode.strings.enterPipelineStep": "Enter the pipeline step to execute",
"viewsContainers.aspirePanel.title": "Aspire",
- "views.runningAppHosts.name": "Running apphosts",
+ "views.runningAppHosts.name": "Running AppHosts",
"views.runningAppHosts.welcome": "No running Aspire apphost detected in this workspace.\n[Refresh](command:aspire-vscode.refreshRunningAppHosts)",
- "views.runningAppHosts.errorWelcome": "The Aspire CLI is not installed or does not support this feature. Install or update the Aspire CLI to get started.\n[Update Aspire CLI](command:aspire-vscode.updateSelf)\n[Refresh](command:aspire-vscode.refreshRunningAppHosts)",
+ "views.runningAppHosts.errorWelcome": "The Aspire CLI is not installed or does not support this feature. Install or update the Aspire CLI and restart VS Code to get started.\n[Update Aspire CLI](command:aspire-vscode.updateSelf)\n[Refresh](command:aspire-vscode.refreshRunningAppHosts)",
"command.refreshRunningAppHosts": "Refresh running apphosts",
"command.openDashboard": "Open Dashboard",
"command.stopAppHost": "Stop",
diff --git a/extension/src/debugger/AspireDebugSession.ts b/extension/src/debugger/AspireDebugSession.ts
index 989d92a3521..0086995b014 100644
--- a/extension/src/debugger/AspireDebugSession.ts
+++ b/extension/src/debugger/AspireDebugSession.ts
@@ -6,8 +6,10 @@ import { AspireResourceExtendedDebugConfiguration, AspireResourceDebugSession, E
import { extensionLogOutputChannel } from "../utils/logging";
import AspireDcpServer, { generateDcpIdPrefix } from "../dcp/AspireDcpServer";
import { spawnCliProcess } from "./languages/cli";
-import { disconnectingFromSession, launchingWithAppHost, launchingWithDirectory, processExceptionOccurred, processExitedWithCode, aspireDashboard } from "../loc/strings";
+import { disconnectingFromSession, launchingWithAppHost, launchingWithDirectory, processExceptionOccurred, processExitedWithCode, aspireDashboard, appHostSessionTerminated } from "../loc/strings";
import { projectDebuggerExtension } from "./languages/dotnet";
+import { AnsiColors } from "../utils/AspireTerminalProvider";
+import { applyTextStyle } from "../utils/strings";
import { nodeDebuggerExtension } from "./languages/node";
import { cleanupRun } from "./runCleanupRegistry";
import AspireRpcServer from "../server/AspireRpcServer";
@@ -239,13 +241,16 @@ export class AspireDebugSession implements vscode.DebugAdapter {
// When the user clicks "restart" on the app host child session,
// we suppress VS Code's automatic child restart and restart the
// entire Aspire debug session instead.
- this.createDebugAdapterTrackerCore(debuggerExtension.debugAdapter, (debugSessionId) => {
- if (debugSessionId === this.debugSessionId) {
- this._appHostRestartRequested = true;
- return true; // suppress VS Code's child restart
+ this.createDebugAdapterTrackerCore(
+ debuggerExtension.debugAdapter,
+ (debugSessionId) => {
+ if (debugSessionId === this.debugSessionId) {
+ this._appHostRestartRequested = true;
+ return true; // suppress VS Code's child restart
+ }
+ return false;
}
- return false;
- });
+ );
let appHostArgs: string[];
let launchConfig;
@@ -290,6 +295,10 @@ export class AspireDebugSession implements vscode.DebugAdapter {
const disposable = vscode.debug.onDidTerminateDebugSession(async session => {
if (this._appHostDebugSession && session.id === this._appHostDebugSession.id) {
+ if (!this._appHostRestartRequested) {
+ this.sendMessageWithEmoji("ℹ️", applyTextStyle(appHostSessionTerminated, AnsiColors.Yellow));
+ }
+
// Only restart the Aspire session when the user explicitly clicked
// "restart" on the app host debug toolbar (detected via DAP tracker).
// All other cases (user stop, process crash/exit) just dispose.
diff --git a/extension/src/debugger/languages/dotnet.ts b/extension/src/debugger/languages/dotnet.ts
index 2009733dfcb..4bcf7da8f57 100644
--- a/extension/src/debugger/languages/dotnet.ts
+++ b/extension/src/debugger/languages/dotnet.ts
@@ -56,60 +56,6 @@ class DotNetService implements IDotNetService {
}
async buildDotNetProject(projectFile: string): Promise {
- const isDevKitEnabled = await this.getAndActivateDevKit();
- if (isDevKitEnabled) {
- this.writeToDebugConsole(lookingForDevkitBuildTask, 'stdout', true);
-
- const tasks = await vscode.tasks.fetchTasks();
- const buildTask = tasks.find(t => t.source === "dotnet" && t.name?.includes('build'));
-
- // The build task may not be registered if there are is no solution in the workspace or if there are no C# projects
- // with .csproj files.
- if (buildTask) {
- // Modify the task to target the specific project
- const projectName = path.basename(projectFile, '.csproj');
-
- // Create a modified task definition with just the project file
- const modifiedDefinition = {
- ...buildTask.definition,
- file: projectFile // This will make it build the specific project directly
- };
-
- // Create a new task with the modified definition
- const modifiedTask = new vscode.Task(
- modifiedDefinition,
- buildTask.scope || vscode.TaskScope.Workspace,
- `build ${projectName}`,
- buildTask.source,
- buildTask.execution,
- buildTask.problemMatchers
- );
-
- extensionLogOutputChannel.info(`Executing build task: ${modifiedTask.name} for project: ${projectFile}`);
- await vscode.tasks.executeTask(modifiedTask);
-
- let disposable: vscode.Disposable = { dispose: () => {} };
- return new Promise((resolve, reject) => {
- disposable = vscode.tasks.onDidEndTaskProcess(async e => {
- if (e.execution.task === modifiedTask) {
- if (e.exitCode !== 0) {
- reject(new Error(buildFailedWithExitCode(e.exitCode ?? 'unknown')));
- }
- else {
- return resolve();
- }
- }
- });
- }).finally(() => disposable.dispose());
- }
- else {
- this.writeToDebugConsole(noCsharpBuildTask, 'stdout', true);
- }
- }
- else {
- this.writeToDebugConsole(csharpDevKitNotInstalled, 'stdout', true);
- }
-
return new Promise((resolve, reject) => {
extensionLogOutputChannel.info(`Building .NET project: ${projectFile} using dotnet CLI`);
@@ -325,10 +271,9 @@ export function createProjectDebuggerExtension(dotNetServiceProducer: (debugSess
const runApiOutput = await dotNetService.getDotNetRunApiOutput(projectPath);
const runApiConfig = getRunApiConfigFromOutput(runApiOutput);
- // Build if the executable doesn't exist or forceBuild is requested
- if ((!(await doesFileExist(runApiConfig.executablePath)) || launchOptions.forceBuild)) {
- await dotNetService.buildDotNetProject(projectPath);
- }
+ // There may be an older cached version of the file-based app, so we
+ // should force a build.
+ await dotNetService.buildDotNetProject(projectPath);
debugConfiguration.program = runApiConfig.executablePath;
diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts
index 6759c733e1c..d65d03d95f7 100644
--- a/extension/src/loc/strings.ts
+++ b/extension/src/loc/strings.ts
@@ -9,6 +9,7 @@ export const codespacesUrl = (url: string) => vscode.l10n.t('Codespaces: {0}', u
export const directLink = vscode.l10n.t('Open local URL');
export const codespacesLink = vscode.l10n.t('Open codespaces URL');
export const openAspireDashboard = vscode.l10n.t('Launch Aspire Dashboard');
+export const settingsLabel = vscode.l10n.t('Settings');
export const aspireDashboard = vscode.l10n.t('Aspire Dashboard');
export const noWorkspaceOpen = vscode.l10n.t('No workspace is open. Please open a folder or workspace before running this command.');
export const failedToShowPromptEmpty = vscode.l10n.t('Failed to show prompt, text was empty.');
@@ -42,6 +43,7 @@ export const failedToStartPythonProgram = (errorMessage: string) => vscode.l10n.
export const csharpSupportNotEnabled = vscode.l10n.t('C# support is not enabled in this workspace. This project should have started through the Aspire CLI.');
export const failedToStartProject = (errorMessage: string) => vscode.l10n.t('Failed to start project: {0}.', errorMessage);
export const dcpServerNotInitialized = vscode.l10n.t('DCP server not initialized - cannot forward debug output.');
+export const appHostSessionTerminated = vscode.l10n.t('The apphost process has terminated. To view console output, select the apphost session from the debug console dropdown.');
export const invalidTokenProvided = vscode.l10n.t('Invalid token provided.');
export const noWorkspaceFolder = vscode.l10n.t('No workspace folder found.');
export const aspireConfigExists = vscode.l10n.t('Aspire launch configuration already exists in launch.json.');
diff --git a/extension/src/server/interactionService.ts b/extension/src/server/interactionService.ts
index a546a1401b6..7cdbd04e08a 100644
--- a/extension/src/server/interactionService.ts
+++ b/extension/src/server/interactionService.ts
@@ -2,7 +2,7 @@ import { MessageConnection } from 'vscode-jsonrpc';
import * as vscode from 'vscode';
import * as fs from 'fs/promises';
import { getRelativePathToWorkspace, isFolderOpenInWorkspace } from '../utils/workspace';
-import { yesLabel, noLabel, directLink, codespacesLink, openAspireDashboard, failedToShowPromptEmpty, incompatibleAppHostError, aspireHostingSdkVersion, aspireCliVersion, requiredCapability, fieldRequired, aspireDebugSessionNotInitialized, errorMessage, failedToStartDebugSession, dashboard, codespaces, selectDirectoryTitle, selectFileTitle } from '../loc/strings';
+import { yesLabel, noLabel, directLink, codespacesLink, openAspireDashboard, settingsLabel, failedToShowPromptEmpty, incompatibleAppHostError, aspireHostingSdkVersion, aspireCliVersion, requiredCapability, fieldRequired, aspireDebugSessionNotInitialized, errorMessage, failedToStartDebugSession, dashboard, codespaces, selectDirectoryTitle, selectFileTitle } from '../loc/strings';
import { ICliRpcClient } from './rpcClient';
import { ProgressNotifier } from './progressNotifier';
import { applyTextStyle, formatText } from '../utils/strings';
@@ -334,11 +334,27 @@ export class InteractionService implements IInteractionService {
this.writeDebugSessionMessage(codespacesUrl, true, AnsiColors.Blue);
}
- // If aspire.enableAspireDashboardAutoLaunch is true, the dashboard will be launched automatically and we do not need
- // to show an information message.
+ // Refresh the Aspire panel so it picks up dashboard URLs for the running app host
+ vscode.commands.executeCommand('aspire-vscode.refreshRunningAppHosts');
+
+ // If aspire.enableAspireDashboardAutoLaunch is 'launch', the dashboard will be launched automatically.
+ // If 'notification', a notification is shown with a link. If 'off', do nothing.
const aspireConfig = vscode.workspace.getConfiguration('aspire');
- const enableDashboardAutoLaunch = aspireConfig.get('enableAspireDashboardAutoLaunch', true);
- if (enableDashboardAutoLaunch) {
+ const rawDashboardAutoLaunch = aspireConfig.get('enableAspireDashboardAutoLaunch', 'launch');
+
+ // Handle legacy boolean values from before this setting was changed to an enum
+ let dashboardAutoLaunch: 'launch' | 'notification' | 'off';
+ if (rawDashboardAutoLaunch === true) {
+ dashboardAutoLaunch = 'launch';
+ } else if (rawDashboardAutoLaunch === false) {
+ dashboardAutoLaunch = 'notification';
+ } else if (rawDashboardAutoLaunch === 'launch' || rawDashboardAutoLaunch === 'notification' || rawDashboardAutoLaunch === 'off') {
+ dashboardAutoLaunch = rawDashboardAutoLaunch;
+ } else {
+ dashboardAutoLaunch = 'launch';
+ }
+
+ if (dashboardAutoLaunch === 'launch') {
// Open the dashboard URL in the configured browser. Prefer codespaces URL if available.
const urlToOpen = codespacesUrl || baseUrl;
const debugSession = this._getAspireDebugSession();
@@ -349,6 +365,10 @@ export class InteractionService implements IInteractionService {
return;
}
+ if (dashboardAutoLaunch === 'off') {
+ return;
+ }
+
const actions: vscode.MessageItem[] = [
{ title: directLink }
];
@@ -357,6 +377,8 @@ export class InteractionService implements IInteractionService {
actions.push({ title: codespacesLink });
}
+ actions.push({ title: settingsLabel });
+
// Delay 1 second to allow a slight pause between progress notification and message
setTimeout(() => {
// Don't await - fire and forget to avoid blocking
@@ -376,6 +398,9 @@ export class InteractionService implements IInteractionService {
else if (selected.title === codespacesLink && codespacesUrl) {
vscode.env.openExternal(vscode.Uri.parse(codespacesUrl));
}
+ else if (selected.title === settingsLabel) {
+ vscode.commands.executeCommand('workbench.action.openSettings', 'aspire.enableAspireDashboardAutoLaunch');
+ }
});
}, 1000);
}
diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts
index 2fa15746210..43aad8b5ae0 100644
--- a/extension/src/test/appHostTreeView.test.ts
+++ b/extension/src/test/appHostTreeView.test.ts
@@ -4,7 +4,7 @@ import { getResourceContextValue, getResourceIcon } from '../views/AspireAppHost
import type { ResourceJson } from '../views/AppHostDataRepository';
function makeResource(overrides: Partial = {}): ResourceJson {
- return {
+ const base: ResourceJson = {
name: 'my-service',
displayName: null,
resourceType: 'Project',
@@ -14,8 +14,9 @@ function makeResource(overrides: Partial = {}): ResourceJson {
dashboardUrl: null,
urls: null,
commands: null,
- ...overrides,
+ properties: null,
};
+ return { ...base, ...overrides } as ResourceJson;
}
suite('shortenPath', () => {
@@ -97,6 +98,7 @@ suite('getResourceContextValue', () => {
}));
assert.strictEqual(result, 'resource:canRestart');
});
+
});
suite('getResourceIcon', () => {
diff --git a/extension/src/test/rpc/interactionServiceTests.test.ts b/extension/src/test/rpc/interactionServiceTests.test.ts
index f2b073aa7c9..e8de2f7b703 100644
--- a/extension/src/test/rpc/interactionServiceTests.test.ts
+++ b/extension/src/test/rpc/interactionServiceTests.test.ts
@@ -189,11 +189,11 @@ suite('InteractionService endpoints', () => {
stub.restore();
});
- test("displayDashboardUrls writes URLs to output channel and shows info message when autoLaunch disabled", async () => {
+ test("displayDashboardUrls writes URLs to output channel and shows info message when autoLaunch is notification", async () => {
const stub = sinon.stub(extensionLogOutputChannel, 'info');
const showInformationMessageStub = sinon.stub(vscode.window, 'showInformationMessage').resolves();
const getConfigurationStub = sinon.stub(vscode.workspace, 'getConfiguration').returns({
- get: (key: string, defaultValue?: any) => key === 'enableAspireDashboardAutoLaunch' ? false : defaultValue
+ get: (key: string, defaultValue?: any) => key === 'enableAspireDashboardAutoLaunch' ? 'notification' : defaultValue
} as any);
const testInfo = await createTestRpcServer();
@@ -212,17 +212,17 @@ suite('InteractionService endpoints', () => {
assert.ok(outputLines.some(line => line.includes(baseUrl)), 'Output should contain base URL');
assert.ok(outputLines.some(line => line.includes(codespacesUrl)), 'Output should contain codespaces URL');
- assert.equal(showInformationMessageStub.callCount, 1, 'Should show info message when autoLaunch is disabled');
+ assert.equal(showInformationMessageStub.callCount, 1, 'Should show info message when autoLaunch is notification');
stub.restore();
showInformationMessageStub.restore();
getConfigurationStub.restore();
});
- test("displayDashboardUrls writes URLs but does not show info message when autoLaunch enabled", async () => {
+ test("displayDashboardUrls writes URLs but does not show info message when autoLaunch is launch", async () => {
const stub = sinon.stub(extensionLogOutputChannel, 'info');
const showInformationMessageStub = sinon.stub(vscode.window, 'showInformationMessage').resolves();
const getConfigurationStub = sinon.stub(vscode.workspace, 'getConfiguration').returns({
- get: (key: string, defaultValue?: any) => key === 'enableAspireDashboardAutoLaunch' ? true : defaultValue
+ get: (key: string, defaultValue?: any) => key === 'enableAspireDashboardAutoLaunch' ? 'launch' : defaultValue
} as any);
const testInfo = await createTestRpcServer();
@@ -239,7 +239,7 @@ suite('InteractionService endpoints', () => {
// No need to wait since no setTimeout should be called when autoLaunch is enabled
assert.ok(outputLines.some(line => line.includes(baseUrl)), 'Output should contain base URL');
assert.ok(outputLines.some(line => line.includes(codespacesUrl)), 'Output should contain codespaces URL');
- assert.equal(showInformationMessageStub.callCount, 0, 'Should not show info message when autoLaunch is enabled');
+ assert.equal(showInformationMessageStub.callCount, 0, 'Should not show info message when autoLaunch is launch');
stub.restore();
showInformationMessageStub.restore();
getConfigurationStub.restore();
diff --git a/extension/src/utils/AspireTerminalProvider.ts b/extension/src/utils/AspireTerminalProvider.ts
index edd482ca16e..e91ce4a2c04 100644
--- a/extension/src/utils/AspireTerminalProvider.ts
+++ b/extension/src/utils/AspireTerminalProvider.ts
@@ -10,6 +10,7 @@ import path from 'path';
export const enum AnsiColors {
Green = '\x1b[32m',
+ Yellow = '\x1b[33m',
Blue = '\x1b[34m',
}
diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts
index 97098feb359..4bc63101fdf 100644
--- a/extension/src/views/AppHostDataRepository.ts
+++ b/extension/src/views/AppHostDataRepository.ts
@@ -27,6 +27,7 @@ export interface ResourceJson {
dashboardUrl: string | null;
urls: ResourceUrlJson[] | null;
commands: Record | null;
+ properties: Record | null;
}
export interface AppHostDisplayInfo {
@@ -64,7 +65,7 @@ export class AppHostDataRepository {
private _describeRestarting = false;
private _describeRestartDelay = 5000;
private _describeRestartTimer: ReturnType | undefined;
- private static readonly _maxDescribeRestartDelay = 60000;
+ private _describeReceivedData = false;
// ── Global mode state (ps polling) ──
private _appHosts: AppHostDisplayInfo[] = [];
@@ -145,6 +146,7 @@ export class AppHostDataRepository {
refresh(): void {
this._stopDescribeWatch();
this._workspaceResources.clear();
+ this._setError(undefined);
this._updateWorkspaceContext();
this._describeRestartDelay = 5000;
this._startDescribeWatch();
@@ -257,6 +259,7 @@ export class AppHostDataRepository {
extensionLogOutputChannel.info('Starting aspire describe --follow for workspace resources');
+ this._describeReceivedData = false;
this._describeProcess = spawnCliProcess(this._terminalProvider, cliPath, args, {
noExtensionVariables: true,
lineCallback: (line) => {
@@ -267,20 +270,29 @@ export class AppHostDataRepository {
this._describeProcess = undefined;
if (!this._disposed && !this._describeRestarting) {
- this._workspaceResources.clear();
- this._setError(undefined);
- this._updateWorkspaceContext();
-
- // Auto-restart with exponential backoff
- const delay = this._describeRestartDelay;
- this._describeRestartDelay = Math.min(this._describeRestartDelay * 2, AppHostDataRepository._maxDescribeRestartDelay);
- extensionLogOutputChannel.info(`Restarting describe --follow in ${delay}ms`);
- this._describeRestartTimer = setTimeout(() => {
- this._describeRestartTimer = undefined;
- if (!this._disposed) {
- this._startDescribeWatch();
- }
- }, delay);
+ if (!this._describeReceivedData && code !== 0) {
+ // The process exited with a non-zero code without ever producing valid data.
+ // This likely means the CLI does not support the describe command.
+ extensionLogOutputChannel.warn('aspire describe --follow exited without producing data; the installed Aspire CLI may not support this feature.');
+ this._workspaceResources.clear();
+ this._setError(errorFetchingAppHosts(`exit code ${code}`));
+ this._updateWorkspaceContext();
+ } else {
+ this._workspaceResources.clear();
+ this._setError(undefined);
+ this._updateWorkspaceContext();
+
+ // Auto-restart with exponential backoff
+ const delay = this._describeRestartDelay;
+ this._describeRestartDelay = Math.min(this._describeRestartDelay * 2, this._getPollingIntervalMs());
+ extensionLogOutputChannel.info(`Restarting describe --follow in ${delay}ms`);
+ this._describeRestartTimer = setTimeout(() => {
+ this._describeRestartTimer = undefined;
+ if (!this._disposed) {
+ this._startDescribeWatch();
+ }
+ }, delay);
+ }
}
this._describeRestarting = false;
},
@@ -320,6 +332,7 @@ export class AppHostDataRepository {
const resource: ResourceJson = JSON.parse(trimmed);
if (resource.name) {
this._workspaceResources.set(resource.name, resource);
+ this._describeReceivedData = true;
this._setError(undefined);
this._describeRestartDelay = 5000; // Reset backoff on successful data
this._updateWorkspaceContext();
diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts
index bc80b18ff78..b98fc02ef5d 100644
--- a/extension/src/views/AspireAppHostTreeProvider.ts
+++ b/extension/src/views/AspireAppHostTreeProvider.ts
@@ -25,6 +25,14 @@ import {
type TreeElement = AppHostItem | DetailItem | ResourcesGroupItem | ResourceItem | WorkspaceResourcesItem;
+function sortResources(resources: ResourceJson[]): ResourceJson[] {
+ return [...resources].sort((a, b) => {
+ const nameA = (a.displayName ?? a.name).toLowerCase();
+ const nameB = (b.displayName ?? b.name).toLowerCase();
+ return nameA.localeCompare(nameB);
+ });
+}
+
function appHostIcon(path?: string): vscode.ThemeIcon {
const icon = path?.endsWith('.csproj') ? 'server-process' : 'file-code';
return new vscode.ThemeIcon(icon, new vscode.ThemeColor('aspire.brandPurple'));
@@ -71,12 +79,19 @@ class ResourcesGroupItem extends vscode.TreeItem {
}
}
+function getParentResourceName(resource: ResourceJson): string | null {
+ return resource.properties?.['resource.parentName'] ?? null;
+}
+
class ResourceItem extends vscode.TreeItem {
- constructor(public readonly resource: ResourceJson, public readonly appHostPid: number | null) {
+ constructor(public readonly resource: ResourceJson, public readonly appHostPid: number | null, hasChildren: boolean) {
const state = resource.state ?? '';
const label = state ? resourceStateLabel(resource.displayName ?? resource.name, state) : (resource.displayName ?? resource.name);
const hasUrls = resource.urls && resource.urls.filter(u => !u.isInternal).length > 0;
- super(label, hasUrls ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None);
+ const collapsible = hasChildren
+ ? vscode.TreeItemCollapsibleState.Expanded
+ : hasUrls ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None;
+ super(label, collapsible);
this.id = appHostPid !== null ? `resource:${appHostPid}:${resource.name}` : `resource:workspace:${resource.name}`;
this.iconPath = getResourceIcon(resource);
this.description = resource.resourceType;
@@ -220,14 +235,17 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider !getParentResourceName(r));
+ for (const resource of sortResources(topLevel)) {
+ const hasChildren = element.resources.some(r => getParentResourceName(r) === resource.name);
+ items.push(new ResourceItem(resource, null, hasChildren));
}
return items;
}
if (element instanceof ResourceItem) {
- return this._getUrlChildren(element);
+ return this._getResourceChildren(element, [...this._repository.workspaceResources]);
}
return [];
@@ -277,16 +295,39 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider new ResourceItem(r, element.appHostPid));
+ const topLevel = element.resources.filter(r => !getParentResourceName(r));
+ return sortResources(topLevel).map(r => {
+ const hasChildren = element.resources.some(c => getParentResourceName(c) === r.name);
+ return new ResourceItem(r, element.appHostPid, hasChildren);
+ });
}
if (element instanceof ResourceItem) {
- return this._getUrlChildren(element);
+ const allResources = this._repository.viewMode === 'workspace'
+ ? [...this._repository.workspaceResources]
+ : this._repository.appHosts.find(a => a.appHostPid === element.appHostPid)?.resources ?? [];
+ return this._getResourceChildren(element, allResources);
}
return [];
}
+ private _getResourceChildren(element: ResourceItem, allResources: readonly ResourceJson[]): TreeElement[] {
+ const items: TreeElement[] = [];
+
+ // Add child resources
+ const children = allResources.filter(r => getParentResourceName(r) === element.resource.name);
+ for (const child of sortResources(children)) {
+ const hasChildren = allResources.some(r => getParentResourceName(r) === child.name);
+ items.push(new ResourceItem(child, element.appHostPid, hasChildren));
+ }
+
+ // Add URL children
+ items.push(...this._getUrlChildren(element));
+
+ return items;
+ }
+
private _getUrlChildren(element: ResourceItem): TreeElement[] {
const urls = element.resource.urls?.filter(u => !u.isInternal) ?? [];
return urls.map(url => new DetailItem(
diff --git a/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs b/playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/AppHost.cs
similarity index 100%
rename from playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/Program.cs
rename to playground/AzureFunctionsEndToEnd/AzureFunctionsEndToEnd.AppHost/AppHost.cs
diff --git a/playground/TypeScriptAppHost/.modules/aspire.ts b/playground/TypeScriptAppHost/.modules/aspire.ts
index 8ade3448ce5..2a1032125cf 100644
--- a/playground/TypeScriptAppHost/.modules/aspire.ts
+++ b/playground/TypeScriptAppHost/.modules/aspire.ts
@@ -8,6 +8,7 @@ import {
AspireClient as AspireClientRpc,
Handle,
MarshalledHandle,
+ AppHostUsageError,
CapabilityError,
registerCallback,
wrapIfHandle,
@@ -239,6 +240,7 @@ export enum EndpointProperty {
Scheme = "Scheme",
TargetPort = "TargetPort",
HostAndPort = "HostAndPort",
+ TlsEnabled = "TlsEnabled",
}
/** Enum type for IconVariant */
@@ -876,6 +878,16 @@ export class EndpointReference {
},
};
+ /** Gets the TlsEnabled property */
+ tlsEnabled = {
+ get: async (): Promise => {
+ return await this._client.invokeCapability(
+ 'Aspire.Hosting.ApplicationModel/EndpointReference.tlsEnabled',
+ { context: this._handle }
+ );
+ },
+ };
+
/** Gets the Port property */
port = {
get: async (): Promise => {
@@ -937,6 +949,15 @@ export class EndpointReference {
);
}
+ /** Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. */
+ async getTlsValue(enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise {
+ const rpcArgs: Record = { context: this._handle, enabledValue, disabledValue };
+ return await this._client.invokeCapability(
+ 'Aspire.Hosting.ApplicationModel/EndpointReference.getTlsValue',
+ rpcArgs
+ );
+ }
+
}
/**
@@ -957,6 +978,11 @@ export class EndpointReferencePromise implements PromiseLike
return this._promise.then(obj => obj.getValueAsync(options));
}
+ /** Gets a conditional expression that resolves to the enabledValue when TLS is enabled on the endpoint, or to the disabledValue otherwise. */
+ getTlsValue(enabledValue: ReferenceExpression, disabledValue: ReferenceExpression): Promise {
+ return this._promise.then(obj => obj.getTlsValue(enabledValue, disabledValue));
+ }
+
}
// ============================================================================
@@ -3202,36 +3228,6 @@ export class ConnectionStringResource extends ResourceBuilderBase {
- const rpcArgs: Record = { builder: this._handle, remoteImageName };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageName',
- rpcArgs
- );
- return new ConnectionStringResource(result, this._client);
- }
-
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): ConnectionStringResourcePromise {
- return new ConnectionStringResourcePromise(this._withRemoteImageNameInternal(remoteImageName));
- }
-
- /** @internal */
- private async _withRemoteImageTagInternal(remoteImageTag: string): Promise {
- const rpcArgs: Record = { builder: this._handle, remoteImageTag };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageTag',
- rpcArgs
- );
- return new ConnectionStringResource(result, this._client);
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): ConnectionStringResourcePromise {
- return new ConnectionStringResourcePromise(this._withRemoteImageTagInternal(remoteImageTag));
- }
-
/** @internal */
private async _withPipelineStepFactoryInternal(stepName: string, callback: (arg: PipelineStepContext) => Promise, dependsOn?: string[], requiredBy?: string[], tags?: string[], description?: string): Promise {
const callbackId = registerCallback(async (argData: unknown) => {
@@ -3441,16 +3437,6 @@ export class ConnectionStringResourcePromise implements PromiseLike obj.excludeFromMcp()));
}
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): ConnectionStringResourcePromise {
- return new ConnectionStringResourcePromise(this._promise.then(obj => obj.withRemoteImageName(remoteImageName)));
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): ConnectionStringResourcePromise {
- return new ConnectionStringResourcePromise(this._promise.then(obj => obj.withRemoteImageTag(remoteImageTag)));
- }
-
/** Adds a pipeline step to the resource */
withPipelineStepFactory(stepName: string, callback: (arg: PipelineStepContext) => Promise, options?: WithPipelineStepFactoryOptions): ConnectionStringResourcePromise {
return new ConnectionStringResourcePromise(this._promise.then(obj => obj.withPipelineStepFactory(stepName, callback, options)));
@@ -3755,36 +3741,6 @@ export class ContainerRegistryResource extends ResourceBuilderBase {
- const rpcArgs: Record = { builder: this._handle, remoteImageName };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageName',
- rpcArgs
- );
- return new ContainerRegistryResource(result, this._client);
- }
-
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): ContainerRegistryResourcePromise {
- return new ContainerRegistryResourcePromise(this._withRemoteImageNameInternal(remoteImageName));
- }
-
- /** @internal */
- private async _withRemoteImageTagInternal(remoteImageTag: string): Promise {
- const rpcArgs: Record = { builder: this._handle, remoteImageTag };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageTag',
- rpcArgs
- );
- return new ContainerRegistryResource(result, this._client);
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): ContainerRegistryResourcePromise {
- return new ContainerRegistryResourcePromise(this._withRemoteImageTagInternal(remoteImageTag));
- }
-
/** @internal */
private async _withPipelineStepFactoryInternal(stepName: string, callback: (arg: PipelineStepContext) => Promise, dependsOn?: string[], requiredBy?: string[], tags?: string[], description?: string): Promise {
const callbackId = registerCallback(async (argData: unknown) => {
@@ -3959,16 +3915,6 @@ export class ContainerRegistryResourcePromise implements PromiseLike obj.excludeFromMcp()));
}
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): ContainerRegistryResourcePromise {
- return new ContainerRegistryResourcePromise(this._promise.then(obj => obj.withRemoteImageName(remoteImageName)));
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): ContainerRegistryResourcePromise {
- return new ContainerRegistryResourcePromise(this._promise.then(obj => obj.withRemoteImageTag(remoteImageTag)));
- }
-
/** Adds a pipeline step to the resource */
withPipelineStepFactory(stepName: string, callback: (arg: PipelineStepContext) => Promise, options?: WithPipelineStepFactoryOptions): ContainerRegistryResourcePromise {
return new ContainerRegistryResourcePromise(this._promise.then(obj => obj.withPipelineStepFactory(stepName, callback, options)));
@@ -8974,36 +8920,6 @@ export class DockerComposeEnvironmentResource extends ResourceBuilderBase {
- const rpcArgs: Record = { builder: this._handle, remoteImageName };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageName',
- rpcArgs
- );
- return new DockerComposeEnvironmentResource(result, this._client);
- }
-
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): DockerComposeEnvironmentResourcePromise {
- return new DockerComposeEnvironmentResourcePromise(this._withRemoteImageNameInternal(remoteImageName));
- }
-
- /** @internal */
- private async _withRemoteImageTagInternal(remoteImageTag: string): Promise {
- const rpcArgs: Record = { builder: this._handle, remoteImageTag };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageTag',
- rpcArgs
- );
- return new DockerComposeEnvironmentResource(result, this._client);
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): DockerComposeEnvironmentResourcePromise {
- return new DockerComposeEnvironmentResourcePromise(this._withRemoteImageTagInternal(remoteImageTag));
- }
-
/** @internal */
private async _withPipelineStepFactoryInternal(stepName: string, callback: (arg: PipelineStepContext) => Promise, dependsOn?: string[], requiredBy?: string[], tags?: string[], description?: string): Promise {
const callbackId = registerCallback(async (argData: unknown) => {
@@ -9235,16 +9151,6 @@ export class DockerComposeEnvironmentResourcePromise implements PromiseLike obj.excludeFromMcp()));
}
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): DockerComposeEnvironmentResourcePromise {
- return new DockerComposeEnvironmentResourcePromise(this._promise.then(obj => obj.withRemoteImageName(remoteImageName)));
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): DockerComposeEnvironmentResourcePromise {
- return new DockerComposeEnvironmentResourcePromise(this._promise.then(obj => obj.withRemoteImageTag(remoteImageTag)));
- }
-
/** Adds a pipeline step to the resource */
withPipelineStepFactory(stepName: string, callback: (arg: PipelineStepContext) => Promise, options?: WithPipelineStepFactoryOptions): DockerComposeEnvironmentResourcePromise {
return new DockerComposeEnvironmentResourcePromise(this._promise.then(obj => obj.withPipelineStepFactory(stepName, callback, options)));
@@ -9585,36 +9491,6 @@ export class DockerComposeServiceResource extends ResourceBuilderBase {
- const rpcArgs: Record = { builder: this._handle, remoteImageName };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageName',
- rpcArgs
- );
- return new DockerComposeServiceResource(result, this._client);
- }
-
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): DockerComposeServiceResourcePromise {
- return new DockerComposeServiceResourcePromise(this._withRemoteImageNameInternal(remoteImageName));
- }
-
- /** @internal */
- private async _withRemoteImageTagInternal(remoteImageTag: string): Promise {
- const rpcArgs: Record = { builder: this._handle, remoteImageTag };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageTag',
- rpcArgs
- );
- return new DockerComposeServiceResource(result, this._client);
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): DockerComposeServiceResourcePromise {
- return new DockerComposeServiceResourcePromise(this._withRemoteImageTagInternal(remoteImageTag));
- }
-
/** @internal */
private async _withPipelineStepFactoryInternal(stepName: string, callback: (arg: PipelineStepContext) => Promise, dependsOn?: string[], requiredBy?: string[], tags?: string[], description?: string): Promise {
const callbackId = registerCallback(async (argData: unknown) => {
@@ -9789,16 +9665,6 @@ export class DockerComposeServiceResourcePromise implements PromiseLike obj.excludeFromMcp()));
}
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): DockerComposeServiceResourcePromise {
- return new DockerComposeServiceResourcePromise(this._promise.then(obj => obj.withRemoteImageName(remoteImageName)));
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): DockerComposeServiceResourcePromise {
- return new DockerComposeServiceResourcePromise(this._promise.then(obj => obj.withRemoteImageTag(remoteImageTag)));
- }
-
/** Adds a pipeline step to the resource */
withPipelineStepFactory(stepName: string, callback: (arg: PipelineStepContext) => Promise, options?: WithPipelineStepFactoryOptions): DockerComposeServiceResourcePromise {
return new DockerComposeServiceResourcePromise(this._promise.then(obj => obj.withPipelineStepFactory(stepName, callback, options)));
@@ -13073,36 +12939,6 @@ export class ExternalServiceResource extends ResourceBuilderBase {
- const rpcArgs: Record = { builder: this._handle, remoteImageName };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageName',
- rpcArgs
- );
- return new ExternalServiceResource(result, this._client);
- }
-
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): ExternalServiceResourcePromise {
- return new ExternalServiceResourcePromise(this._withRemoteImageNameInternal(remoteImageName));
- }
-
- /** @internal */
- private async _withRemoteImageTagInternal(remoteImageTag: string): Promise {
- const rpcArgs: Record = { builder: this._handle, remoteImageTag };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageTag',
- rpcArgs
- );
- return new ExternalServiceResource(result, this._client);
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): ExternalServiceResourcePromise {
- return new ExternalServiceResourcePromise(this._withRemoteImageTagInternal(remoteImageTag));
- }
-
/** @internal */
private async _withPipelineStepFactoryInternal(stepName: string, callback: (arg: PipelineStepContext) => Promise, dependsOn?: string[], requiredBy?: string[], tags?: string[], description?: string): Promise {
const callbackId = registerCallback(async (argData: unknown) => {
@@ -13282,16 +13118,6 @@ export class ExternalServiceResourcePromise implements PromiseLike obj.excludeFromMcp()));
}
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): ExternalServiceResourcePromise {
- return new ExternalServiceResourcePromise(this._promise.then(obj => obj.withRemoteImageName(remoteImageName)));
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): ExternalServiceResourcePromise {
- return new ExternalServiceResourcePromise(this._promise.then(obj => obj.withRemoteImageTag(remoteImageTag)));
- }
-
/** Adds a pipeline step to the resource */
withPipelineStepFactory(stepName: string, callback: (arg: PipelineStepContext) => Promise, options?: WithPipelineStepFactoryOptions): ExternalServiceResourcePromise {
return new ExternalServiceResourcePromise(this._promise.then(obj => obj.withPipelineStepFactory(stepName, callback, options)));
@@ -16823,36 +16649,6 @@ export class ParameterResource extends ResourceBuilderBase {
- const rpcArgs: Record = { builder: this._handle, remoteImageName };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageName',
- rpcArgs
- );
- return new ParameterResource(result, this._client);
- }
-
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): ParameterResourcePromise {
- return new ParameterResourcePromise(this._withRemoteImageNameInternal(remoteImageName));
- }
-
- /** @internal */
- private async _withRemoteImageTagInternal(remoteImageTag: string): Promise {
- const rpcArgs: Record = { builder: this._handle, remoteImageTag };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageTag',
- rpcArgs
- );
- return new ParameterResource(result, this._client);
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): ParameterResourcePromise {
- return new ParameterResourcePromise(this._withRemoteImageTagInternal(remoteImageTag));
- }
-
/** @internal */
private async _withPipelineStepFactoryInternal(stepName: string, callback: (arg: PipelineStepContext) => Promise, dependsOn?: string[], requiredBy?: string[], tags?: string[], description?: string): Promise {
const callbackId = registerCallback(async (argData: unknown) => {
@@ -17032,16 +16828,6 @@ export class ParameterResourcePromise implements PromiseLike
return new ParameterResourcePromise(this._promise.then(obj => obj.excludeFromMcp()));
}
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): ParameterResourcePromise {
- return new ParameterResourcePromise(this._promise.then(obj => obj.withRemoteImageName(remoteImageName)));
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): ParameterResourcePromise {
- return new ParameterResourcePromise(this._promise.then(obj => obj.withRemoteImageTag(remoteImageTag)));
- }
-
/** Adds a pipeline step to the resource */
withPipelineStepFactory(stepName: string, callback: (arg: PipelineStepContext) => Promise, options?: WithPipelineStepFactoryOptions): ParameterResourcePromise {
return new ParameterResourcePromise(this._promise.then(obj => obj.withPipelineStepFactory(stepName, callback, options)));
@@ -20971,36 +20757,6 @@ export class PostgresDatabaseResource extends ResourceBuilderBase {
- const rpcArgs: Record = { builder: this._handle, remoteImageName };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageName',
- rpcArgs
- );
- return new PostgresDatabaseResource(result, this._client);
- }
-
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): PostgresDatabaseResourcePromise {
- return new PostgresDatabaseResourcePromise(this._withRemoteImageNameInternal(remoteImageName));
- }
-
- /** @internal */
- private async _withRemoteImageTagInternal(remoteImageTag: string): Promise {
- const rpcArgs: Record = { builder: this._handle, remoteImageTag };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageTag',
- rpcArgs
- );
- return new PostgresDatabaseResource(result, this._client);
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): PostgresDatabaseResourcePromise {
- return new PostgresDatabaseResourcePromise(this._withRemoteImageTagInternal(remoteImageTag));
- }
-
/** @internal */
private async _withPipelineStepFactoryInternal(stepName: string, callback: (arg: PipelineStepContext) => Promise, dependsOn?: string[], requiredBy?: string[], tags?: string[], description?: string): Promise {
const callbackId = registerCallback(async (argData: unknown) => {
@@ -21224,16 +20980,6 @@ export class PostgresDatabaseResourcePromise implements PromiseLike obj.excludeFromMcp()));
}
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): PostgresDatabaseResourcePromise {
- return new PostgresDatabaseResourcePromise(this._promise.then(obj => obj.withRemoteImageName(remoteImageName)));
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): PostgresDatabaseResourcePromise {
- return new PostgresDatabaseResourcePromise(this._promise.then(obj => obj.withRemoteImageTag(remoteImageTag)));
- }
-
/** Adds a pipeline step to the resource */
withPipelineStepFactory(stepName: string, callback: (arg: PipelineStepContext) => Promise, options?: WithPipelineStepFactoryOptions): PostgresDatabaseResourcePromise {
return new PostgresDatabaseResourcePromise(this._promise.then(obj => obj.withPipelineStepFactory(stepName, callback, options)));
@@ -30152,12 +29898,6 @@ export class RedisResource extends ResourceBuilderBase {
{ context: this._handle }
);
},
- set: async (value: boolean): Promise => {
- await this._client.invokeCapability(
- 'Aspire.Hosting.ApplicationModel/RedisResource.setTlsEnabled',
- { context: this._handle, value }
- );
- }
};
/** Gets the ConnectionStringExpression property */
@@ -33889,6 +33629,36 @@ export class ComputeResource extends ResourceBuilderBase
super(handle, client);
}
+ /** @internal */
+ private async _withRemoteImageNameInternal(remoteImageName: string): Promise {
+ const rpcArgs: Record = { builder: this._handle, remoteImageName };
+ const result = await this._client.invokeCapability(
+ 'Aspire.Hosting/withRemoteImageName',
+ rpcArgs
+ );
+ return new ComputeResource(result, this._client);
+ }
+
+ /** Sets the remote image name for publishing */
+ withRemoteImageName(remoteImageName: string): ComputeResourcePromise {
+ return new ComputeResourcePromise(this._withRemoteImageNameInternal(remoteImageName));
+ }
+
+ /** @internal */
+ private async _withRemoteImageTagInternal(remoteImageTag: string): Promise {
+ const rpcArgs: Record = { builder: this._handle, remoteImageTag };
+ const result = await this._client.invokeCapability(
+ 'Aspire.Hosting/withRemoteImageTag',
+ rpcArgs
+ );
+ return new ComputeResource(result, this._client);
+ }
+
+ /** Sets the remote image tag for publishing */
+ withRemoteImageTag(remoteImageTag: string): ComputeResourcePromise {
+ return new ComputeResourcePromise(this._withRemoteImageTagInternal(remoteImageTag));
+ }
+
/** @internal */
private async _publishAsDockerComposeServiceInternal(configure: (arg1: DockerComposeServiceResource, arg2: Service) => Promise): Promise {
const configureId = registerCallback(async (argsData: unknown) => {
@@ -33929,6 +33699,16 @@ export class ComputeResourcePromise implements PromiseLike {
return this._promise.then(onfulfilled, onrejected);
}
+ /** Sets the remote image name for publishing */
+ withRemoteImageName(remoteImageName: string): ComputeResourcePromise {
+ return new ComputeResourcePromise(this._promise.then(obj => obj.withRemoteImageName(remoteImageName)));
+ }
+
+ /** Sets the remote image tag for publishing */
+ withRemoteImageTag(remoteImageTag: string): ComputeResourcePromise {
+ return new ComputeResourcePromise(this._promise.then(obj => obj.withRemoteImageTag(remoteImageTag)));
+ }
+
/** Publishes the resource as a Docker Compose service with custom service configuration */
publishAsDockerComposeService(configure: (arg1: DockerComposeServiceResource, arg2: Service) => Promise): ComputeResourcePromise {
return new ComputeResourcePromise(this._promise.then(obj => obj.publishAsDockerComposeService(configure)));
@@ -34266,36 +34046,6 @@ export class Resource extends ResourceBuilderBase {
return new ResourcePromise(this._excludeFromMcpInternal());
}
- /** @internal */
- private async _withRemoteImageNameInternal(remoteImageName: string): Promise {
- const rpcArgs: Record = { builder: this._handle, remoteImageName };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageName',
- rpcArgs
- );
- return new Resource(result, this._client);
- }
-
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): ResourcePromise {
- return new ResourcePromise(this._withRemoteImageNameInternal(remoteImageName));
- }
-
- /** @internal */
- private async _withRemoteImageTagInternal(remoteImageTag: string): Promise {
- const rpcArgs: Record = { builder: this._handle, remoteImageTag };
- const result = await this._client.invokeCapability(
- 'Aspire.Hosting/withRemoteImageTag',
- rpcArgs
- );
- return new Resource(result, this._client);
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): ResourcePromise {
- return new ResourcePromise(this._withRemoteImageTagInternal(remoteImageTag));
- }
-
/** @internal */
private async _withPipelineStepFactoryInternal(stepName: string, callback: (arg: PipelineStepContext) => Promise, dependsOn?: string[], requiredBy?: string[], tags?: string[], description?: string): Promise {
const callbackId = registerCallback(async (argData: unknown) => {
@@ -34470,16 +34220,6 @@ export class ResourcePromise implements PromiseLike {
return new ResourcePromise(this._promise.then(obj => obj.excludeFromMcp()));
}
- /** Sets the remote image name for publishing */
- withRemoteImageName(remoteImageName: string): ResourcePromise {
- return new ResourcePromise(this._promise.then(obj => obj.withRemoteImageName(remoteImageName)));
- }
-
- /** Sets the remote image tag for publishing */
- withRemoteImageTag(remoteImageTag: string): ResourcePromise {
- return new ResourcePromise(this._promise.then(obj => obj.withRemoteImageTag(remoteImageTag)));
- }
-
/** Adds a pipeline step to the resource */
withPipelineStepFactory(stepName: string, callback: (arg: PipelineStepContext) => Promise, options?: WithPipelineStepFactoryOptions): ResourcePromise {
return new ResourcePromise(this._promise.then(obj => obj.withPipelineStepFactory(stepName, callback, options)));
@@ -35668,7 +35408,7 @@ export async function createBuilder(options?: CreateBuilderOptions): Promise {
const error = reason instanceof Error ? reason : new Error(String(reason));
- if (reason instanceof CapabilityError) {
+ if (reason instanceof AppHostUsageError) {
+ console.error(`\n❌ AppHost Error: ${error.message}`);
+ } else if (reason instanceof CapabilityError) {
console.error(`\n❌ Capability Error: ${error.message}`);
console.error(` Code: ${(reason as CapabilityError).code}`);
if ((reason as CapabilityError).capability) {
@@ -35699,8 +35441,12 @@ process.on('unhandledRejection', (reason: unknown) => {
});
process.on('uncaughtException', (error: Error) => {
- console.error(`\n❌ Uncaught Exception: ${error.message}`);
- if (error.stack) {
+ if (error instanceof AppHostUsageError) {
+ console.error(`\n❌ AppHost Error: ${error.message}`);
+ } else {
+ console.error(`\n❌ Uncaught Exception: ${error.message}`);
+ }
+ if (!(error instanceof AppHostUsageError) && error.stack) {
console.error(error.stack);
}
process.exit(1);
diff --git a/playground/TypeScriptAppHost/.modules/base.ts b/playground/TypeScriptAppHost/.modules/base.ts
index 7778b0f1737..6256537c773 100644
--- a/playground/TypeScriptAppHost/.modules/base.ts
+++ b/playground/TypeScriptAppHost/.modules/base.ts
@@ -1,5 +1,5 @@
// aspire.ts - Core Aspire types: base classes, ReferenceExpression
-import { Handle, AspireClient, MarshalledHandle } from './transport.js';
+import { Handle, AspireClient, MarshalledHandle, registerCancellation, registerHandleWrapper, unregisterCancellation } from './transport.js';
// Re-export transport types for convenience
export { Handle, AspireClient, CapabilityError, registerCallback, unregisterCallback, registerCancellation, unregisterCancellation } from './transport.js';
@@ -43,22 +43,46 @@ export class ReferenceExpression {
private readonly _format?: string;
private readonly _valueProviders?: unknown[];
+ // Conditional mode fields
+ private readonly _condition?: unknown;
+ private readonly _whenTrue?: ReferenceExpression;
+ private readonly _whenFalse?: ReferenceExpression;
+ private readonly _matchValue?: string;
+
// Handle mode fields (when wrapping a server-returned handle)
private readonly _handle?: Handle;
private readonly _client?: AspireClient;
constructor(format: string, valueProviders: unknown[]);
constructor(handle: Handle, client: AspireClient);
- constructor(handleOrFormat: Handle | string, clientOrValueProviders: AspireClient | unknown[]) {
- if (typeof handleOrFormat === 'string') {
- this._format = handleOrFormat;
- this._valueProviders = clientOrValueProviders as unknown[];
+ constructor(condition: unknown, matchValue: string, whenTrue: ReferenceExpression, whenFalse: ReferenceExpression);
+ constructor(
+ handleOrFormatOrCondition: Handle | string | unknown,
+ clientOrValueProvidersOrMatchValue: AspireClient | unknown[] | string,
+ whenTrueOrWhenFalse?: ReferenceExpression,
+ whenFalse?: ReferenceExpression
+ ) {
+ if (typeof handleOrFormatOrCondition === 'string') {
+ this._format = handleOrFormatOrCondition;
+ this._valueProviders = clientOrValueProvidersOrMatchValue as unknown[];
+ } else if (handleOrFormatOrCondition instanceof Handle) {
+ this._handle = handleOrFormatOrCondition;
+ this._client = clientOrValueProvidersOrMatchValue as AspireClient;
} else {
- this._handle = handleOrFormat;
- this._client = clientOrValueProviders as AspireClient;
+ this._condition = handleOrFormatOrCondition;
+ this._matchValue = (clientOrValueProvidersOrMatchValue as string) ?? 'True';
+ this._whenTrue = whenTrueOrWhenFalse;
+ this._whenFalse = whenFalse;
}
}
+ /**
+ * Gets whether this reference expression is conditional.
+ */
+ get isConditional(): boolean {
+ return this._condition !== undefined;
+ }
+
/**
* Creates a reference expression from a tagged template literal.
*
@@ -82,16 +106,46 @@ export class ReferenceExpression {
return new ReferenceExpression(format, valueProviders);
}
+ /**
+ * Creates a conditional reference expression from its constituent parts.
+ *
+ * @param condition - A value provider whose result is compared to matchValue
+ * @param whenTrue - The expression to use when the condition matches
+ * @param whenFalse - The expression to use when the condition does not match
+ * @param matchValue - The value to compare the condition against (defaults to "True")
+ * @returns A ReferenceExpression instance in conditional mode
+ */
+ static createConditional(
+ condition: unknown,
+ matchValue: string,
+ whenTrue: ReferenceExpression,
+ whenFalse: ReferenceExpression
+ ): ReferenceExpression {
+ return new ReferenceExpression(condition, matchValue, whenTrue, whenFalse);
+ }
+
/**
* Serializes the reference expression for JSON-RPC transport.
- * In template-literal mode, uses the $expr format.
+ * In expression mode, uses the $expr format with format + valueProviders.
+ * In conditional mode, uses the $expr format with condition + whenTrue + whenFalse.
* In handle mode, delegates to the handle's serialization.
*/
- toJSON(): { $expr: { format: string; valueProviders?: unknown[] } } | MarshalledHandle {
+ toJSON(): { $expr: { format: string; valueProviders?: unknown[] } | { condition: unknown; whenTrue: unknown; whenFalse: unknown; matchValue: string } } | MarshalledHandle {
if (this._handle) {
return this._handle.toJSON();
}
+ if (this.isConditional) {
+ return {
+ $expr: {
+ condition: this._condition instanceof Handle ? this._condition.toJSON() : this._condition,
+ whenTrue: this._whenTrue!.toJSON(),
+ whenFalse: this._whenFalse!.toJSON(),
+ matchValue: this._matchValue!
+ }
+ };
+ }
+
return {
$expr: {
format: this._format!,
@@ -100,6 +154,30 @@ export class ReferenceExpression {
};
}
+ /**
+ * Resolves the expression to its string value on the server.
+ * Only available on server-returned ReferenceExpression instances (handle mode).
+ *
+ * @param cancellationToken - Optional AbortSignal for cancellation support
+ * @returns The resolved string value, or null if the expression resolves to null
+ */
+ async getValue(cancellationToken?: AbortSignal): Promise {
+ if (!this._handle || !this._client) {
+ throw new Error('getValue is only available on server-returned ReferenceExpression instances');
+ }
+ const cancellationTokenId = registerCancellation(cancellationToken);
+ try {
+ const rpcArgs: Record = { context: this._handle };
+ if (cancellationTokenId !== undefined) rpcArgs.cancellationToken = cancellationTokenId;
+ return await this._client.invokeCapability(
+ 'Aspire.Hosting.ApplicationModel/getValue',
+ rpcArgs
+ );
+ } finally {
+ unregisterCancellation(cancellationTokenId);
+ }
+ }
+
/**
* String representation for debugging.
*/
@@ -107,10 +185,17 @@ export class ReferenceExpression {
if (this._handle) {
return `ReferenceExpression(handle)`;
}
+ if (this.isConditional) {
+ return `ReferenceExpression(conditional)`;
+ }
return `ReferenceExpression(${this._format})`;
}
}
+registerHandleWrapper('Aspire.Hosting/Aspire.Hosting.ApplicationModel.ReferenceExpression', (handle, client) =>
+ new ReferenceExpression(handle, client)
+);
+
/**
* Extracts a value for use in reference expressions.
* Supports handles (objects) and string literals.
diff --git a/playground/TypeScriptAppHost/.modules/transport.ts b/playground/TypeScriptAppHost/.modules/transport.ts
index 7bddd74beff..7ee1ba87e3f 100644
--- a/playground/TypeScriptAppHost/.modules/transport.ts
+++ b/playground/TypeScriptAppHost/.modules/transport.ts
@@ -213,6 +213,75 @@ export class CapabilityError extends Error {
}
}
+/**
+ * Error thrown when the AppHost script uses the generated SDK incorrectly.
+ */
+export class AppHostUsageError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'AppHostUsageError';
+ }
+}
+
+function isPromiseLike(value: unknown): value is PromiseLike {
+ return (
+ value !== null &&
+ (typeof value === 'object' || typeof value === 'function') &&
+ 'then' in value &&
+ typeof (value as { then?: unknown }).then === 'function'
+ );
+}
+
+function validateCapabilityArgs(
+ capabilityId: string,
+ args?: Record
+): void {
+ if (!args) {
+ return;
+ }
+
+ const seen = new Set