diff --git a/docs/specs/cli-output-formats.md b/docs/specs/cli-output-formats.md index bbe2393303a..1872557e34c 100644 --- a/docs/specs/cli-output-formats.md +++ b/docs/specs/cli-output-formats.md @@ -95,32 +95,7 @@ If discovery finds no AppHost candidates, the stream emits no lines. The stream ] ``` -Use `aspire ps --format json --resources` to include each AppHost's current resources: - -```json -[ - { - "appHostPath": "/path/to/MyApp.AppHost/MyApp.AppHost.csproj", - "appHostPid": 12345, - "status": "running", - "resources": [ - { - "name": "api", - "displayName": "api", - "resourceType": "Project", - "state": "Running", - "healthStatus": "Healthy", - "urls": [ - { - "name": "https", - "url": "https://localhost:5001" - } - ] - } - ] - } -] -``` +`aspire ps` returns only AppHost-level information. Use [`aspire describe`](#aspire-describe) to inspect or stream the resources that belong to an AppHost. `aspire ps --follow --format json` streams newline-delimited AppHost objects. New or changed AppHosts are emitted with `"status": "running"`. When an AppHost stops, it is emitted one last time with `"status": "stopped"` so consumers can remove it from their state: @@ -172,7 +147,7 @@ Use `aspire ps --format json --resources` to include each AppHost's current reso #### Resource fields -`aspire describe`, `aspire describe --follow`, and `aspire ps --resources` share the resource object shape: +`aspire describe` and `aspire describe --follow` share the resource object shape: | Field | Description | | ----- | ----------- | diff --git a/extension/Extension.proj b/extension/Extension.proj index ff8c1048973..5436af60c54 100644 --- a/extension/Extension.proj +++ b/extension/Extension.proj @@ -31,13 +31,13 @@ - - - + + @@ -88,12 +88,12 @@ - + - + diff --git a/extension/build.ps1 b/extension/build.ps1 index 21069d7ff13..ccd4be4305b 100644 --- a/extension/build.ps1 +++ b/extension/build.ps1 @@ -10,10 +10,9 @@ if (-not (Get-Command node -ErrorAction SilentlyContinue)) { exit 1 } -# Check for yarn -if (-not (Get-Command yarn -ErrorAction SilentlyContinue)) { - Write-Error "Error: yarn is not installed. Please install yarn first." - Write-Host "You can install yarn by running: npm install -g yarn" +# Check for Corepack so the build uses the Yarn Classic version that matches extension/yarn.lock. +if (-not (Get-Command corepack -ErrorAction SilentlyContinue)) { + Write-Error "Error: Corepack is not installed. Please install a Node.js version that includes Corepack." exit 1 } @@ -41,7 +40,7 @@ Set-Location $PSScriptRoot Write-Host "" Write-Host "Running yarn install..." -yarn install --frozen-lockfile --non-interactive +corepack yarn@1.22.22 install --frozen-lockfile --non-interactive if ($LASTEXITCODE -ne 0) { Write-Error "yarn install failed with exit code $LASTEXITCODE" @@ -50,7 +49,7 @@ if ($LASTEXITCODE -ne 0) { Write-Host "" Write-Host "Running yarn compile..." -yarn compile +corepack yarn@1.22.22 compile if ($LASTEXITCODE -ne 0) { Write-Error "yarn compile failed with exit code $LASTEXITCODE" diff --git a/extension/build.sh b/extension/build.sh index 4b7c7e79bac..110967f4215 100755 --- a/extension/build.sh +++ b/extension/build.sh @@ -9,10 +9,9 @@ if ! command -v node &> /dev/null; then exit 1 fi -# Check for yarn -if ! command -v yarn &> /dev/null; then - echo "Error: yarn is not installed. Please install yarn first." - echo "You can install yarn by running: npm install -g yarn" +# Check for Corepack so the build uses the Yarn Classic version that matches extension/yarn.lock. +if ! command -v corepack &> /dev/null; then + echo "Error: Corepack is not installed. Please install a Node.js version that includes Corepack." exit 1 fi @@ -38,11 +37,11 @@ cd "$SCRIPT_DIR" echo "" echo "Running yarn install..." -yarn install --frozen-lockfile --non-interactive +corepack yarn@1.22.22 install --frozen-lockfile --non-interactive echo "" echo "Running yarn compile..." -yarn compile +corepack yarn@1.22.22 compile echo "" echo "Building Aspire CLI..." diff --git a/extension/package.json b/extension/package.json index e3106820e0d..095c39616c3 100644 --- a/extension/package.json +++ b/extension/package.json @@ -3,7 +3,7 @@ "displayName": "Aspire", "description": "%extension.description%", "publisher": "microsoft-aspire", - "version": "1.0.9", + "version": "1.10.0", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "icon": "dotnet-aspire-logo-128.png", "license": "SEE LICENSE IN LICENSE.TXT", diff --git a/extension/src/test/appHostDataRepository.test.ts b/extension/src/test/appHostDataRepository.test.ts index dd1b6031a6d..145f7f5ba27 100644 --- a/extension/src/test/appHostDataRepository.test.ts +++ b/extension/src/test/appHostDataRepository.test.ts @@ -260,7 +260,6 @@ suite('AppHostDataRepository', () => { appHostPid: 1234, cliPid: null, dashboardUrl: null, - resources: null, }])); assert.ok(repository.errorMessage?.includes('describe failed'), repository.errorMessage); @@ -274,7 +273,7 @@ suite('AppHostDataRepository', () => { let getAppHostsLineCallback: ((line: string) => void) | undefined; const getAppHostsProcess = new TestChildProcess(); const describeProcess = new TestChildProcess(); - const psResourcesProcess = new TestChildProcess(); + const psFollowProcess = new TestChildProcess(); const psFallbackProcess = new TestChildProcess(); const replacementDescribeProcess = new TestChildProcess(); const psSuccessProcess = new TestChildProcess(); @@ -283,7 +282,7 @@ suite('AppHostDataRepository', () => { return getAppHostsProcess; }); spawnStub.onSecondCall().returns(describeProcess); - spawnStub.onThirdCall().returns(psResourcesProcess); + spawnStub.onThirdCall().returns(psFollowProcess); spawnStub.onCall(3).returns(psFallbackProcess); spawnStub.onCall(4).returns(replacementDescribeProcess); spawnStub.onCall(5).returns(psSuccessProcess); @@ -312,12 +311,7 @@ suite('AppHostDataRepository', () => { psFollowOptions.exitCallback(1); await waitForAppHostDiscovery(); - const psResourcesOptions = spawnStub.getCall(3).args[3]; - psResourcesOptions.stderrCallback('resources unavailable'); - psResourcesOptions.exitCallback(1); - await waitForAppHostDiscovery(); - - const psFallbackOptions = spawnStub.getCall(4).args[3]; + const psFallbackOptions = spawnStub.getCall(3).args[3]; psFallbackOptions.stderrCallback('ps failed'); psFallbackOptions.exitCallback(1); assert.ok(repository.errorMessage?.includes('ps failed'), repository.errorMessage); @@ -375,7 +369,7 @@ suite('AppHostDataRepository', () => { assert.strictEqual(repository.viewMode, 'workspace'); assert.strictEqual(spawnStub.callCount, 2); - assert.deepStrictEqual(spawnStub.secondCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.secondCall.args[2], ['ps', '--follow', '--format', 'json']); } finally { repository.dispose(); workspaceFoldersStub.restore(); @@ -421,7 +415,241 @@ suite('AppHostDataRepository', () => { assert.strictEqual(describeProcess.killed, false); assert.strictEqual(spawnStub.callCount, 3); assert.deepStrictEqual(spawnStub.secondCall.args[2], ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/apps/Store/AppHost.csproj']); - assert.deepStrictEqual(spawnStub.thirdCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.thirdCall.args[2], ['ps', '--follow', '--format', 'json']); + } finally { + repository.dispose(); + workspaceFoldersStub.restore(); + } + }); + + test('multi-AppHost workspace retargets describe to the only running AppHost', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + const describeProcesses: TestChildProcess[] = []; + const describeCalls: { args: string[]; options: any }[] = []; + let psOptions: any; + spawnStub.callsFake((_terminalProvider, _command, args, options) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeCalls.push({ args, options }); + const process = new TestChildProcess(); + describeProcesses.push(process); + return process; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForAppHostDiscovery(); + assert.ok(getAppHostsLineCallback); + + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/apps/Store/AppHost.csproj', + all_project_file_candidates: [ + '/workspace/apps/Store/AppHost.csproj', + '/workspace/samples/Store/AppHost.csproj', + ], + })); + await waitForAppHostDiscovery(); + + assert.strictEqual(describeCalls.length, 1); + assert.deepStrictEqual(describeCalls[0].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/apps/Store/AppHost.csproj']); + assert.ok(psOptions); + + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/samples/Store/AppHost.csproj', + appHostPid: 125881, + cliPid: 125738, + dashboardUrl: 'https://localhost:17193/login?t=061212', + }, + ])); + await waitForMicrotasks(); + + assert.strictEqual(repository.workspaceAppHostPath, '/workspace/samples/Store/AppHost.csproj'); + assert.strictEqual(repository.workspaceAppHostName, 'samples/Store/AppHost.csproj'); + assert.strictEqual(repository.workspaceAppHost?.appHostPid, 125881); + assert.strictEqual(describeProcesses[0].killed, true); + assert.strictEqual(describeCalls.length, 2); + assert.deepStrictEqual(describeCalls[1].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/samples/Store/AppHost.csproj']); + + describeCalls[1].options.lineCallback(JSON.stringify({ name: 'api', resourceType: 'Project', state: 'Running' })); + assert.strictEqual(repository.workspaceResources.length, 1); + assert.strictEqual(repository.workspaceResources[0].name, 'api'); + } finally { + repository.dispose(); + workspaceFoldersStub.restore(); + } + }); + + test('multi-AppHost workspace does not retarget describe when multiple candidate AppHosts are running', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + const describeProcesses: TestChildProcess[] = []; + const describeCalls: { args: string[]; options: any }[] = []; + let psOptions: any; + spawnStub.callsFake((_terminalProvider, _command, args, options) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeCalls.push({ args, options }); + const process = new TestChildProcess(); + describeProcesses.push(process); + return process; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForAppHostDiscovery(); + assert.ok(getAppHostsLineCallback); + + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/apps/Store/AppHost.csproj', + all_project_file_candidates: [ + '/workspace/apps/Store/AppHost.csproj', + '/workspace/samples/Store/AppHost.csproj', + '/workspace/tools/Admin/AppHost.csproj', + ], + })); + await waitForAppHostDiscovery(); + + assert.strictEqual(describeCalls.length, 1); + assert.deepStrictEqual(describeCalls[0].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/apps/Store/AppHost.csproj']); + assert.ok(psOptions); + + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/samples/Store/AppHost.csproj', + appHostPid: 125881, + cliPid: 125738, + dashboardUrl: 'https://localhost:17193/login?t=061212', + }, + { + appHostPath: '/workspace/tools/Admin/AppHost.csproj', + appHostPid: 125882, + cliPid: 125739, + dashboardUrl: 'https://localhost:17194/login?t=061213', + }, + ])); + await waitForMicrotasks(); + + assert.strictEqual(repository.workspaceAppHostPath, '/workspace/apps/Store/AppHost.csproj'); + assert.strictEqual(repository.workspaceAppHostName, 'apps/Store/AppHost.csproj'); + assert.strictEqual(repository.workspaceAppHost, undefined); + // No retarget, but global describe streams start for the non-selected running AppHosts + // so their resources appear in the workspace tree. + assert.strictEqual(describeCalls.length, 3); + assert.deepStrictEqual(describeCalls[1].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/samples/Store/AppHost.csproj']); + assert.deepStrictEqual(describeCalls[2].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/tools/Admin/AppHost.csproj']); + } finally { + repository.dispose(); + workspaceFoldersStub.restore(); + } + }); + + test('non-selected running AppHosts in workspace get resources from per-AppHost describe streams', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + const describeProcesses: TestChildProcess[] = []; + const describeCalls: { args: string[]; options: any }[] = []; + let psOptions: any; + spawnStub.callsFake((_terminalProvider: any, _command: any, args: string[], options: any) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeCalls.push({ args, options }); + const process = new TestChildProcess(); + describeProcesses.push(process); + return process; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForAppHostDiscovery(); + assert.ok(getAppHostsLineCallback); + + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/apps/Store/AppHost.csproj', + all_project_file_candidates: [ + '/workspace/apps/Store/AppHost.csproj', + '/workspace/samples/Store/AppHost.csproj', + ], + })); + await waitForAppHostDiscovery(); + assert.strictEqual(describeCalls.length, 1); + assert.ok(psOptions); + + // Simulate both AppHosts running + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/apps/Store/AppHost.csproj', + appHostPid: 125880, + cliPid: 125737, + dashboardUrl: 'https://localhost:17192/login?t=061211', + }, + { + appHostPath: '/workspace/samples/Store/AppHost.csproj', + appHostPid: 125881, + cliPid: 125738, + dashboardUrl: 'https://localhost:17193/login?t=061212', + }, + ])); + await waitForMicrotasks(); + + // Global describe for non-selected AppHost spawns asynchronously after resolving CLI path + await waitForCondition(() => describeCalls.length >= 2, 'global describe for non-selected AppHost should start'); + + // Initial workspace describe + global describe for the non-selected AppHost + // (workspace describe restart is still pending on a timer) + assert.strictEqual(describeCalls.length, 2); + assert.deepStrictEqual(describeCalls[1].args, ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/samples/Store/AppHost.csproj']); + + // Simulate resource data arriving on the non-selected AppHost's describe stream (NDJSON format) + describeCalls[1].options.lineCallback(JSON.stringify({ name: 'redis', resourceType: 'Container', state: 'Running' })); + await waitForMicrotasks(); + + // The non-selected AppHost should have its resources populated + const nonSelectedAppHost = repository.appHosts.find((a: any) => a.appHostPath === '/workspace/samples/Store/AppHost.csproj'); + assert.ok(nonSelectedAppHost); + assert.ok(nonSelectedAppHost.resources); + assert.strictEqual(nonSelectedAppHost.resources!.length, 1); + assert.strictEqual(nonSelectedAppHost.resources![0].name, 'redis'); } finally { repository.dispose(); workspaceFoldersStub.restore(); @@ -499,7 +727,6 @@ suite('AppHostDataRepository', () => { { appHostPath: configuredAppHostPath, appHostPid: 125881, - resources: [], }, ])); assert.strictEqual(repository.workspaceAppHost?.appHostPath, configuredAppHostPath); @@ -577,6 +804,10 @@ suite('AppHostDataRepository', () => { status: 'possibly-unbuildable', }, ])); + // aspire ls exit handler awaits getConfiguredAppHostPathFromWorkspaceRoot, which + // probes for aspire.config.json / .aspire/settings.json via vscode workspace fs. + // That probe can take more than one macrotask on Windows, so poll for completion + // instead of relying on a single setTimeout(0) tick. await waitForCondition( () => repository.workspaceAppHostPath === '/workspace/apps/Store/AppHost.csproj', 'buildable AppHost discovery did not finish'); @@ -689,13 +920,12 @@ suite('AppHostDataRepository', () => { await waitForAppHostDiscovery(); assert.ok(psOptions); - assert.deepStrictEqual(psArgs, ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(psArgs, ['ps', '--follow', '--format', 'json']); psOptions.lineCallback(JSON.stringify([{ appHostPath: '/workspace/apphost/apphost.cs', appHostPid: 125881, cliPid: 125738, dashboardUrl: 'https://localhost:17193/login?t=061212', - resources: [], }])); assert.strictEqual(repository.workspaceResources.length, 0); @@ -769,7 +999,6 @@ suite('AppHostDataRepository', () => { appHostPid: 125881, cliPid: 125738, dashboardUrl: 'https://localhost:17193/login?t=061212', - resources: [], }, ])); @@ -895,7 +1124,6 @@ suite('AppHostDataRepository', () => { { appHostPath: '/workspace/labs/ops/apphost.cs', appHostPid: 125881, - resources: [], }, ])); @@ -913,6 +1141,143 @@ suite('AppHostDataRepository', () => { } }); + test('workspace describe exit clears stale running AppHost before ps stop snapshot', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + const executeCommandStub = sinon.stub(vscode.commands, 'executeCommand').resolves(undefined); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + const describeProcess = new TestChildProcess(); + let describeOptions: any; + let psOptions: any; + spawnStub.callsFake((_terminalProvider, _command, args, options) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeOptions = options; + return describeProcess; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForMicrotasks(); + + assert.ok(getAppHostsLineCallback); + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/labs/ops/apphost.cs', + all_project_file_candidates: ['/workspace/labs/ops/apphost.cs'], + })); + await waitForAppHostDiscovery(); + + assert.ok(describeOptions); + assert.ok(psOptions); + describeOptions.lineCallback(JSON.stringify({ name: 'worker', resourceType: 'Project', state: 'Running' })); + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/labs/ops/apphost.cs', + appHostPid: 125881, + }, + ])); + + assert.strictEqual(repository.workspaceResources.length, 1); + assert.strictEqual(repository.workspaceAppHost?.appHostPid, 125881); + assert.strictEqual(repository.appHosts.length, 1); + + describeOptions.exitCallback(0); + + assert.strictEqual(repository.workspaceResources.length, 0); + assert.strictEqual(repository.workspaceAppHost, undefined); + assert.strictEqual(repository.appHosts.length, 0); + + const noRunningContextCalls = executeCommandStub.getCalls().filter(call => + call.args[0] === 'setContext' && call.args[1] === 'aspire.noRunningAppHosts'); + assert.strictEqual(noRunningContextCalls.at(-1)?.args[2], true); + } finally { + repository.dispose(); + executeCommandStub.restore(); + workspaceFoldersStub.restore(); + } + }); + + test('workspace ps start restarts describe after earlier empty describe exit', async () => { + const workspaceFoldersStub = stubWorkspaceFolders([{ + uri: vscode.Uri.file('/workspace'), + name: 'workspace', + index: 0, + }]); + let getAppHostsLineCallback: ((line: string) => void) | undefined; + let psOptions: any; + const describeProcesses: TestChildProcess[] = []; + const describeOptions: any[] = []; + spawnStub.callsFake((_terminalProvider, _command, args, options) => { + if (args[0] === 'ls') { + getAppHostsLineCallback = createLsLineCallback(options); + } + if (args[0] === 'describe') { + describeOptions.push(options); + const process = new TestChildProcess(); + describeProcesses.push(process); + return process; + } + if (args[0] === 'ps') { + psOptions = options; + } + return new TestChildProcess(); + }); + + const repository = new AppHostDataRepository(terminalProvider); + + try { + repository.activate(); + repository.setPanelVisible(true); + await waitForMicrotasks(); + + assert.ok(getAppHostsLineCallback); + getAppHostsLineCallback(JSON.stringify({ + selected_project_file: '/workspace/labs/ops/apphost.cs', + all_project_file_candidates: ['/workspace/labs/ops/apphost.cs'], + })); + await waitForAppHostDiscovery(); + + assert.strictEqual(describeOptions.length, 1); + describeOptions[0].exitCallback(0); + assert.strictEqual(repository.workspaceResources.length, 0); + assert.strictEqual(Boolean(repository.workspaceAppHost), false); + + assert.ok(psOptions); + psOptions.lineCallback(JSON.stringify([ + { + appHostPath: '/workspace/labs/ops/apphost.cs', + appHostPid: 125881, + }, + ])); + await waitForMicrotasks(); + + assert.strictEqual(repository.workspaceAppHost?.appHostPid, 125881); + assert.strictEqual(describeOptions.length, 2); + + describeOptions[1].lineCallback(JSON.stringify({ name: 'worker', resourceType: 'Project', state: 'Running' })); + assert.strictEqual(repository.workspaceResources.length, 1); + assert.strictEqual(repository.workspaceResources[0].name, 'worker'); + assert.strictEqual(describeProcesses[0].killed, false); + assert.strictEqual(describeProcesses[1].killed, false); + } finally { + repository.dispose(); + workspaceFoldersStub.restore(); + } + }); + test('late close from stopped describe watch does not orphan replacement watch', async () => { const firstChildProcess = new TestChildProcess(); const secondChildProcess = new TestChildProcess(); @@ -1012,7 +1377,7 @@ suite('AppHostDataRepository global polling', () => { repository.setPanelVisible(true); await waitForMicrotasks(); - assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json']); repository.setPanelVisible(false); @@ -1031,52 +1396,58 @@ suite('AppHostDataRepository global polling', () => { repository.setPanelVisible(true); await waitForMicrotasks(); - assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--follow', '--format', 'json']); - const lineCallback = spawnStub.firstCall.args[3].lineCallback; - lineCallback(JSON.stringify({ + const psLineCallback = spawnStub.firstCall.args[3].lineCallback; + psLineCallback(JSON.stringify({ appHostPath: '/workspace/AppHost.csproj', appHostPid: 1234, status: 'running', - resources: [ - { name: 'api', resourceType: 'Project', state: 'Running' } - ] })); + await waitForMicrotasks(); assert.strictEqual(repository.appHosts.length, 1); assert.strictEqual(repository.appHosts[0].appHostPath, '/workspace/AppHost.csproj'); + + // The repository should now have spawned `aspire describe --follow --apphost ` + // for the discovered AppHost so the global tree can show resources. + const describeCall = spawnStub.getCalls().find(call => + Array.isArray(call.args[2]) && call.args[2][0] === 'describe' && call.args[2].includes('/workspace/AppHost.csproj')); + assert.ok(describeCall, 'expected aspire describe --follow to spawn for the discovered AppHost'); + assert.deepStrictEqual(describeCall.args[2], ['describe', '--follow', '--format', 'json', '--apphost', '/workspace/AppHost.csproj']); + + const describeLineCallback = describeCall.args[3].lineCallback; + describeLineCallback(JSON.stringify({ name: 'api', resourceType: 'Project', state: 'Running' })); assert.strictEqual(repository.appHosts[0].resources?.[0].name, 'api'); - lineCallback(JSON.stringify({ + psLineCallback(JSON.stringify({ appHostPath: '/workspace/OtherAppHost.csproj', appHostPid: 5678, status: 'running', - resources: [] })); + await waitForMicrotasks(); assert.strictEqual(repository.appHosts.length, 2); assert.strictEqual(repository.appHosts[1].appHostPath, '/workspace/OtherAppHost.csproj'); assert.deepStrictEqual(repository.appHosts[1].resources, []); - lineCallback(JSON.stringify({ + psLineCallback(JSON.stringify({ appHostPath: '/workspace/AppHost.csproj', appHostPid: 9999, status: 'running', - resources: [] })); + await waitForMicrotasks(); assert.strictEqual(repository.appHosts.length, 3); assert.strictEqual(repository.appHosts[2].appHostPath, '/workspace/AppHost.csproj'); assert.strictEqual(repository.appHosts[2].appHostPid, 9999); - lineCallback(JSON.stringify({ + psLineCallback(JSON.stringify({ appHostPath: '/workspace/AppHost.csproj', appHostPid: 1234, status: 'stopped', - resources: [ - { name: 'api', resourceType: 'Project', state: 'Running' } - ] })); + await waitForMicrotasks(); assert.strictEqual(repository.appHosts.length, 2); assert.strictEqual(repository.appHosts[0].appHostPath, '/workspace/OtherAppHost.csproj'); @@ -1107,7 +1478,7 @@ suite('AppHostDataRepository global polling', () => { repository.dispose(); }); - test('cli path failure does not disable resources polling', async () => { + test('cli path failure does not disable ps polling', async () => { const clock = sinon.useFakeTimers(); getCliPathStub.onFirstCall().rejects(new Error('CLI path unavailable')); getCliPathStub.onSecondCall().resolves('aspire'); @@ -1125,7 +1496,7 @@ suite('AppHostDataRepository global polling', () => { await waitForMicrotasks(); assert.strictEqual(spawnStub.calledOnce, true); - assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--format', 'json', '--resources']); + assert.deepStrictEqual(spawnStub.firstCall.args[2], ['ps', '--format', 'json']); } finally { repository.dispose(); clock.restore(); @@ -1157,7 +1528,7 @@ suite('AppHostDataRepository global polling', () => { } }); - test('stopped ps does not start fallback after resources failure', async () => { + test('stopped ps does not start fallback after exit', async () => { const childProcess = new TestChildProcess(); spawnStub.returns(childProcess); const repository = new AppHostDataRepository(terminalProvider); @@ -1221,6 +1592,99 @@ suite('AppHostDataRepository global polling', () => { repository.dispose(); }); + + test('global mode spawns describe per AppHost and tears down on AppHost removal', async () => { + const spawned: { args: string[]; process: TestChildProcess; options: any }[] = []; + spawnStub.callsFake((_terminalProvider, _cliPath, args, options) => { + const process = new TestChildProcess(); + spawned.push({ args, process, options }); + return process; + }); + const repository = new AppHostDataRepository(terminalProvider); + + repository.activate(); + repository.setViewMode('global'); + repository.setPanelVisible(true); + await waitForMicrotasks(); + + const psCall = spawned.find(call => call.args[0] === 'ps'); + assert.ok(psCall); + + psCall.options.lineCallback(JSON.stringify({ + appHostPath: '/workspace/AppHost.csproj', + appHostPid: 1234, + status: 'running', + })); + psCall.options.lineCallback(JSON.stringify({ + appHostPath: '/workspace/OtherAppHost.csproj', + appHostPid: 5678, + status: 'running', + })); + await waitForMicrotasks(); + + const describeCalls = spawned.filter(call => call.args[0] === 'describe'); + assert.strictEqual(describeCalls.length, 2); + const paths = describeCalls.map(call => call.args[call.args.indexOf('--apphost') + 1]).sort(); + assert.deepStrictEqual(paths, ['/workspace/AppHost.csproj', '/workspace/OtherAppHost.csproj']); + + const firstDescribe = describeCalls.find(call => call.args.includes('/workspace/AppHost.csproj'))!; + firstDescribe.options.lineCallback(JSON.stringify({ name: 'api', resourceType: 'Project', state: 'Running' })); + firstDescribe.options.lineCallback(JSON.stringify({ name: 'db', resourceType: 'Container', state: 'Running' })); + + const first = repository.appHosts.find(a => a.appHostPath === '/workspace/AppHost.csproj'); + assert.ok(first); + assert.strictEqual(first.resources?.length, 2); + assert.deepStrictEqual(first.resources?.map(r => r.name).sort(), ['api', 'db']); + + // Stop the first AppHost — its describe stream should be torn down. + psCall.options.lineCallback(JSON.stringify({ + appHostPath: '/workspace/AppHost.csproj', + appHostPid: 1234, + status: 'stopped', + })); + await waitForMicrotasks(); + + assert.strictEqual(firstDescribe.process.killed, true); + assert.strictEqual(repository.appHosts.length, 1); + assert.strictEqual(repository.appHosts[0].appHostPath, '/workspace/OtherAppHost.csproj'); + + repository.dispose(); + }); + + test('global describe streams are stopped when switching to workspace mode', async () => { + const spawned: { args: string[]; process: TestChildProcess; options: any }[] = []; + spawnStub.callsFake((_terminalProvider, _cliPath, args, options) => { + const process = new TestChildProcess(); + spawned.push({ args, process, options }); + return process; + }); + const repository = new AppHostDataRepository(terminalProvider); + + repository.activate(); + repository.setViewMode('global'); + repository.setPanelVisible(true); + await waitForMicrotasks(); + + const psCall = spawned.find(call => call.args[0] === 'ps'); + assert.ok(psCall); + psCall.options.lineCallback(JSON.stringify({ + appHostPath: '/workspace/AppHost.csproj', + appHostPid: 1234, + status: 'running', + })); + await waitForMicrotasks(); + + const describeCall = spawned.find(call => call.args[0] === 'describe'); + assert.ok(describeCall); + assert.strictEqual(describeCall.process.killed, false); + + repository.setViewMode('workspace'); + await waitForMicrotasks(); + + assert.strictEqual(describeCall.process.killed, true); + + repository.dispose(); + }); }); suite('AppHostDataRepository AppHost-file gate', () => { diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index 42cd419fe54..4bb6b4e351a 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -90,13 +90,26 @@ export interface AppHostDisplayInfo { export type ViewMode = 'workspace' | 'global'; +interface GlobalDescribeStream { + appHostPath: string; + process: ChildProcessWithoutNullStreams | undefined; + resources: Map; + restartTimer: ReturnType | undefined; + restartDelay: number; + version: number; +} + /** * Central data repository for app host and resource information. * - * Owns two independent data sources: + * Owns three independent data sources: * - `aspire describe --follow` (workspace mode) — streams resource updates * via NDJSON for the selected workspace AppHost. Only active while the * tree-view panel is visible **and** workspace mode is selected. + * - `aspire describe --follow --apphost ` (global mode fan-out) — one + * stream per AppHost discovered by `ps`, merged into `appHost.resources` + * so the global multi-AppHost tree can show nested resources. `ps` itself + * only emits AppHost-level data. * - `aspire ps` polling — periodically fetches running app hosts. In global * mode this backs the full tree; in workspace mode it confirms whether the * selected workspace AppHost is running when the resource stream is empty. @@ -123,14 +136,25 @@ export class AppHostDataRepository { // ── Running AppHost state (ps polling) ── private _appHosts: AppHostDisplayInfo[] = []; + // Cached JSON serialization of `_appHosts` after the most recent reconcile so + // _handlePsOutput can detect real changes. We can't compare raw `ps` output to + // `_appHosts` directly because the in-memory state has merged resources, while + // `ps` no longer emits them (#17479) — see _handlePsOutput for the rationale. + private _appHostsSnapshot = '[]'; private _workspaceAppHost: AppHostDisplayInfo | undefined; private _pollingInterval: ReturnType | undefined; private _psProcesses = new Set(); private _psFetchVersion = 0; - private _supportsResources = true; private _supportsPsFollow = true; private _fetchInProgress = false; + // ── Global mode per-AppHost describe streams ── + // In global mode `ps` only returns AppHost-level data, so to populate + // `appHost.resources` for the multi-AppHost tree we fan out one + // `aspire describe --follow --apphost ` per discovered AppHost and + // merge the streams. Keyed by appHostPath. + private _globalDescribeStreams = new Map(); + // ── Workspace app host (from aspire ls) ── // The singular fields track a selected/default workspace AppHost. The candidate // paths track every buildable AppHost found by `aspire ls`, so workspace-mode @@ -255,6 +279,7 @@ export class AppHostDataRepository { refresh(): void { this._stopDescribeWatch(); + this._stopAllGlobalDescribes(); this._workspaceResources.clear(); this._clearErrors(); this._updateWorkspaceContext(); @@ -276,6 +301,7 @@ export class AppHostDataRepository { this._disposed = true; this._stopPolling(); this._stopDescribeWatch(); + this._stopAllGlobalDescribes(); this._configChangeDisposable.dispose(); this._appHostDiscoveryChangeDisposable.dispose(); this._onDidChangeData.dispose(); @@ -329,6 +355,12 @@ export class AppHostDataRepository { } else { this._stopPolling(); } + + // Global describe fan-out is only active while in global mode with the + // panel/editor showing. _reconcileGlobalDescribes handles both starting + // streams (when there are AppHosts to follow) and tearing them down + // (when we leave global mode or hide the panel). + this._reconcileGlobalDescribes(); } // ── Workspace app host (from aspire ls) ── @@ -400,6 +432,13 @@ export class AppHostDataRepository { this._workspaceAppHostName = candidateIndex >= 0 ? appHostLabels[candidateIndex] : shortenPath(appHostPath); } + private _setWorkspaceAppHostPathFromCurrentCandidates(appHostPath: string): void { + this._workspaceAppHostPath = appHostPath; + const appHostLabels = shortenPaths(this._workspaceAppHostCandidatePaths); + const candidateIndex = this._workspaceAppHostCandidatePaths.findIndex(candidatePath => isMatchingAppHostPath(candidatePath, appHostPath)); + this._workspaceAppHostName = candidateIndex >= 0 ? appHostLabels[candidateIndex] : shortenPath(appHostPath); + } + private _setWorkspaceAppHostCandidatePaths(appHostCandidates: readonly AppHostCandidate[]): void { this._workspaceAppHostCandidatePaths = appHostCandidates.map(candidate => candidate.path); } @@ -489,6 +528,7 @@ export class AppHostDataRepository { // once more with backoff in case the apphost is restarting; if that // attempt also produces no data we'll fall into the branch above. this._workspaceResources.clear(); + this._clearStoppedWorkspaceAppHost(); this._setDescribeError(undefined); this._updateWorkspaceContext(); @@ -572,6 +612,14 @@ export class AppHostDataRepository { } } + private _clearStoppedWorkspaceAppHost(): void { + const appHostPath = this._workspaceAppHost?.appHostPath ?? this._workspaceAppHostPath; + this._workspaceAppHost = undefined; + this._appHosts = appHostPath + ? this._appHosts.filter(appHost => !isMatchingAppHostPath(appHost.appHostPath, appHostPath)) + : []; + } + private _handleDescribeLine(line: string): boolean { const trimmed = line.trim(); if (!trimmed) { @@ -607,6 +655,184 @@ export class AppHostDataRepository { return undefined; } + // ── Global mode: per-AppHost describe fan-out ── + // `ps` is AppHost-level only, so to keep the global multi-AppHost tree + // populated with resources we spin up one `aspire describe --follow --apphost ` + // per AppHost in `_appHosts` and merge the streams into appHost.resources. + + private _reconcileGlobalDescribes(): void { + if (this._disposed || this._viewMode !== 'global' || !this._dataActive) { + this._stopAllGlobalDescribes(); + return; + } + + const currentPaths = new Set(this._appHosts.map(a => a.appHostPath)); + for (const path of Array.from(this._globalDescribeStreams.keys())) { + if (!currentPaths.has(path)) { + this._stopGlobalDescribe(path); + } + } + for (const appHost of this._appHosts) { + if (!this._globalDescribeStreams.has(appHost.appHostPath)) { + this._startGlobalDescribe(appHost.appHostPath); + } + } + this._attachGlobalResourcesToAppHosts(); + } + + private _attachGlobalResourcesToAppHosts(): void { + for (const appHost of this._appHosts) { + const stream = this._globalDescribeStreams.get(appHost.appHostPath); + appHost.resources = stream ? Array.from(stream.resources.values()) : null; + } + } + + private _startGlobalDescribe(appHostPath: string): void { + const stream: GlobalDescribeStream = { + appHostPath, + process: undefined, + resources: new Map(), + restartTimer: undefined, + restartDelay: 5000, + version: 0, + }; + this._globalDescribeStreams.set(appHostPath, stream); + const startVersion = ++stream.version; + + this._terminalProvider.getAspireCliExecutablePath().then(cliPath => { + // Bail if we were stopped, replaced, or torn down while resolving the cli path. + if (this._disposed || this._globalDescribeStreams.get(appHostPath) !== stream || startVersion !== stream.version) { + return; + } + + const args = ['describe', '--follow', '--format', 'json', '--apphost', appHostPath]; + extensionLogOutputChannel.info(`Starting aspire describe --follow for AppHost ${appHostPath}`); + + const childProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { + noExtensionVariables: true, + lineCallback: (line) => { + if (this._globalDescribeStreams.get(appHostPath) !== stream || stream.process !== childProcess) { + return; + } + this._handleGlobalDescribeLine(stream, line); + }, + stderrCallback: (data) => { + // Per-AppHost describe errors should not pollute the global error banner, + // but they MUST be logged so users can diagnose missing resources for + // non-selected AppHosts (e.g., CLI too old to support `describe --apphost`). + extensionLogOutputChannel.warn(`aspire describe --follow stderr for ${appHostPath}: ${data}`); + }, + exitCallback: (code) => { + if (this._globalDescribeStreams.get(appHostPath) !== stream || stream.process !== childProcess) { + return; + } + extensionLogOutputChannel.info(`aspire describe --follow for ${appHostPath} exited with code ${code}`); + stream.process = undefined; + if (this._disposed) { + return; + } + + // AppHost is no longer running — drop the stream entirely; the + // next ps reconcile will recreate it if the AppHost comes back. + if (!this._appHosts.some(a => a.appHostPath === appHostPath)) { + this._globalDescribeStreams.delete(appHostPath); + return; + } + + stream.resources.clear(); + this._attachGlobalResourcesToAppHosts(); + this._onDidChangeData.fire(); + + const delay = stream.restartDelay; + stream.restartDelay = Math.min(stream.restartDelay * 2, this._getPollingIntervalMs()); + stream.restartTimer = setTimeout(() => { + stream.restartTimer = undefined; + if (this._disposed) { + return; + } + if (this._globalDescribeStreams.get(appHostPath) !== stream) { + return; + } + if (!this._appHosts.some(a => a.appHostPath === appHostPath)) { + this._globalDescribeStreams.delete(appHostPath); + return; + } + this._globalDescribeStreams.delete(appHostPath); + this._startGlobalDescribe(appHostPath); + }, delay); + }, + errorCallback: (error) => { + if (this._globalDescribeStreams.get(appHostPath) !== stream || stream.process !== childProcess) { + return; + } + extensionLogOutputChannel.warn(`aspire describe --follow for ${appHostPath} error: ${error.message}`); + stream.process = undefined; + // Node's `spawn` can fire `error` (e.g., ENOENT when the CLI binary is missing) + // without a subsequent `exit`, which would normally drive the restart loop. + // Drop the dead entry so the next ps reconcile recreates it instead of leaving + // a zombie that blocks reconcile from re-starting the stream. + this._globalDescribeStreams.delete(appHostPath); + stream.resources.clear(); + this._attachGlobalResourcesToAppHosts(); + this._onDidChangeData.fire(); + } + }); + stream.process = childProcess; + }).catch(error => { + extensionLogOutputChannel.warn(`Failed to start describe for ${appHostPath}: ${error}`); + // Same hazard as errorCallback above: getAspireCliExecutablePath() can reject + // (CLI missing, permission denied, etc.) without ever firing the spawn error/exit + // callbacks that would normally clean up. Drop the dead entry so the next + // reconcile recreates it instead of leaving a zombie that blocks reconcile + // from re-starting the stream. + if (this._globalDescribeStreams.get(appHostPath) === stream) { + this._globalDescribeStreams.delete(appHostPath); + } + }); + } + + private _handleGlobalDescribeLine(stream: GlobalDescribeStream, line: string): void { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + try { + const resource: ResourceJson = JSON.parse(trimmed); + if (resource.name) { + stream.resources.set(resource.name, resource); + stream.restartDelay = 5000; + this._attachGlobalResourcesToAppHosts(); + this._onDidChangeData.fire(); + } + } catch (e) { + extensionLogOutputChannel.warn(`Failed to parse describe NDJSON line for ${stream.appHostPath}: ${e}`); + } + } + + private _stopGlobalDescribe(appHostPath: string): void { + const stream = this._globalDescribeStreams.get(appHostPath); + if (!stream) { + return; + } + this._globalDescribeStreams.delete(appHostPath); + stream.version++; + if (stream.restartTimer) { + clearTimeout(stream.restartTimer); + stream.restartTimer = undefined; + } + if (stream.process) { + const childProcess = stream.process; + stream.process = undefined; + this._terminateProcess(childProcess, `aspire describe --follow (${appHostPath})`); + } + } + + private _stopAllGlobalDescribes(): void { + for (const path of Array.from(this._globalDescribeStreams.keys())) { + this._stopGlobalDescribe(path); + } + } + private _updateWorkspaceContext(options?: { clearLoading?: boolean }): void { const hasWorkspaceAppHost = this._workspaceAppHost !== undefined; const hasResources = this._workspaceResources.size > 0; @@ -705,9 +931,6 @@ export class AppHostDataRepository { }; const args = ['ps', '--follow', '--format', 'json']; - if (this._supportsResources) { - args.push('--resources'); - } psProcess = spawnCliProcess(this._terminalProvider, cliPath, args, { noExtensionVariables: true, @@ -772,34 +995,16 @@ export class AppHostDataRepository { const fetchVersion = ++this._psFetchVersion; const args = ['ps', '--format', 'json']; - if (this._supportsResources) { - args.push('--resources'); - } this._runPsCommand(args, fetchVersion, (code, stdout, stderr) => { if (code === 0) { this._setPsError(undefined); this._handlePsOutput(stdout); - this._fetchInProgress = false; - } else if (this._supportsResources) { - this._supportsResources = false; - extensionLogOutputChannel.info('aspire ps --resources failed, falling back to aspire ps without --resources'); - this._runPsCommand(['ps', '--format', 'json'], fetchVersion, (retryCode, retryStdout, retryStderr) => { - if (retryCode === 0) { - this._setPsError(undefined); - this._handlePsOutput(retryStdout); - } else { - this._loadingGlobal = false; - this._updateLoadingContext(); - this._setPsError(errorFetchingAppHosts(retryStderr || `exit code ${retryCode}`)); - } - this._fetchInProgress = false; - }); } else { this._loadingGlobal = false; this._updateLoadingContext(); this._setPsError(errorFetchingAppHosts(stderr || `exit code ${code}`)); - this._fetchInProgress = false; } + this._fetchInProgress = false; }); } @@ -859,8 +1064,18 @@ export class AppHostDataRepository { return; } - const changed = JSON.stringify(appHosts) !== JSON.stringify(this._appHosts); + // Compare against the previous post-reconcile snapshot rather than the + // raw ps payload. `appHosts` here lacks the `resources` field (ps no longer + // emits it after #17479), while `this._appHosts` was mutated by the prior + // _attachGlobalResourcesToAppHosts call to include resources — a direct + // JSON.stringify compare would always report `changed` once any stream + // produced resources, triggering spurious _onDidChangeData.fire() calls. + const previousSnapshot = this._appHostsSnapshot; this._appHosts = appHosts; + this._reconcileGlobalDescribes(); + const nextSnapshot = JSON.stringify(this._appHosts); + const changed = nextSnapshot !== previousSnapshot; + this._appHostsSnapshot = nextSnapshot; if (this._loadingGlobal) { this._loadingGlobal = false; @@ -888,13 +1103,29 @@ export class AppHostDataRepository { } private _handleWorkspacePsOutput(appHosts: readonly AppHostDisplayInfo[]): void { - const workspaceAppHostPath = this._workspaceAppHostPath; + let workspaceAppHostPath = this._workspaceAppHostPath; const workspaceAppHosts = this._workspaceAppHostCandidatePaths.length > 0 ? appHosts.filter(appHost => this._workspaceAppHostCandidatePaths.some(candidatePath => isMatchingAppHostPath(appHost.appHostPath, candidatePath))) : []; - const workspaceAppHost = workspaceAppHostPath + let workspaceAppHost = workspaceAppHostPath ? workspaceAppHosts.find(appHost => isMatchingAppHostPath(appHost.appHostPath, workspaceAppHostPath)) : undefined; + let workspaceAppHostPathChanged = false; + + if (!workspaceAppHost && workspaceAppHosts.length === 1) { + workspaceAppHost = workspaceAppHosts[0]; + workspaceAppHostPathChanged = !isMatchingAppHostPath(workspaceAppHostPath, workspaceAppHost.appHostPath); + if (workspaceAppHostPathChanged) { + extensionLogOutputChannel.info(`Retargeting workspace AppHost describe to running AppHost ${workspaceAppHost.appHostPath}`); + this._stopDescribeWatch({ clearWorkspaceResources: true }); + this._setWorkspaceAppHostPathFromCurrentCandidates(workspaceAppHost.appHostPath); + workspaceAppHostPath = this._workspaceAppHostPath; + this._setDescribeError(undefined); + this._describeRestartDelay = 5000; + } + } + + const workspaceAppHostStarted = workspaceAppHost !== undefined && (this._workspaceAppHost === undefined || workspaceAppHostPathChanged); const changed = JSON.stringify(workspaceAppHosts) !== JSON.stringify(this._appHosts) || JSON.stringify(workspaceAppHost) !== JSON.stringify(this._workspaceAppHost); @@ -905,11 +1136,64 @@ export class AppHostDataRepository { this._appHosts = workspaceAppHosts; this._workspaceAppHost = workspaceAppHost; + // When multiple workspace AppHost candidates exist, start per-AppHost describe + // streams for running AppHosts that are NOT the selected one (the workspace + // describe stream already handles the selected AppHost). This ensures every + // running AppHost displayed in the multi-AppHost workspace tree has resources. + if (this._workspaceAppHostCandidatePaths.length > 1) { + this._reconcileWorkspaceDescribes(workspaceAppHosts); + } + + if (workspaceAppHostStarted + && this._shouldWatchWorkspace + && !this._describeProcess + && !this._describeStartPending + && !this._describeRestartTimer) { + this._startDescribeWatch(); + } + if (changed || this._loadingWorkspace) { this._updateWorkspaceContext({ clearLoading: true }); } } + /** + * In multi-candidate workspace mode, start/stop per-AppHost describe streams for + * running workspace AppHosts that are NOT the currently selected one. The workspace + * describe stream (via `_startDescribeWatch`) handles the selected AppHost; this + * method fans out global describe streams for the remaining running AppHosts so that + * each one displayed in the workspace tree has its resources populated. + */ + private _reconcileWorkspaceDescribes(workspaceAppHosts: readonly AppHostDisplayInfo[]): void { + const selectedPath = this._workspaceAppHostPath; + + // Determine which non-selected workspace AppHosts need a describe stream. + const desiredPaths = new Set( + workspaceAppHosts + .filter(a => !selectedPath || !isMatchingAppHostPath(a.appHostPath, selectedPath)) + .map(a => a.appHostPath) + ); + + // Stop streams for AppHosts that are no longer running (or became selected). + for (const path of Array.from(this._globalDescribeStreams.keys())) { + if (!desiredPaths.has(path)) { + this._stopGlobalDescribe(path); + } + } + + // Start streams for newly running non-selected AppHosts. + for (const appHost of workspaceAppHosts) { + if (selectedPath && isMatchingAppHostPath(appHost.appHostPath, selectedPath)) { + continue; + } + if (!this._globalDescribeStreams.has(appHost.appHostPath)) { + this._startGlobalDescribe(appHost.appHostPath); + } + } + + this._attachGlobalResourcesToAppHosts(); + } + private async _runPsCommand(args: string[], fetchVersion: number, callback: (code: number, stdout: string, stderr: string) => void): Promise { let cliPath: string; try { diff --git a/src/Aspire.Cli/Commands/PsCommand.cs b/src/Aspire.Cli/Commands/PsCommand.cs index d36e128e336..76082600915 100644 --- a/src/Aspire.Cli/Commands/PsCommand.cs +++ b/src/Aspire.Cli/Commands/PsCommand.cs @@ -4,7 +4,6 @@ using System.CommandLine; using System.Globalization; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading.Channels; using Aspire.Cli.Backchannel; @@ -13,7 +12,6 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; -using Aspire.Shared.Model.Serialization; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -35,9 +33,6 @@ internal sealed class AppHostDisplayInfo [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? LogFilePath { get; init; } - - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public List? Resources { get; set; } } internal static class AppHostDisplayStatus @@ -48,18 +43,6 @@ internal static class AppHostDisplayStatus [JsonSerializable(typeof(List))] [JsonSerializable(typeof(AppHostDisplayInfo))] -[JsonSerializable(typeof(ResourceJson))] -[JsonSerializable(typeof(ResourceUrlJson))] -[JsonSerializable(typeof(ResourceVolumeJson))] -[JsonSerializable(typeof(ResourceRelationshipJson))] -[JsonSerializable(typeof(ResourceHealthReportJson))] -[JsonSerializable(typeof(ResourceCommandJson))] -[JsonSerializable(typeof(ResourceCommandArgumentJson[]))] -[JsonSerializable(typeof(JsonNode))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] -[JsonSerializable(typeof(Dictionary))] [JsonSourceGenerationOptions(WriteIndented = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] internal sealed partial class PsCommandJsonContext : JsonSerializerContext { @@ -98,16 +81,6 @@ internal sealed partial class PsCommand : BaseCommand Description = PsCommandStrings.JsonOptionDescription }; - private static readonly Option s_resourcesOption = new("--resources") - { - Description = PsCommandStrings.ResourcesOptionDescription - }; - - private static readonly Option s_includeHiddenOption = new("--include-hidden") - { - Description = SharedCommandStrings.IncludeHiddenOptionDescription - }; - private static readonly Option s_followOption = new("--follow", "-f") { Description = PsCommandStrings.FollowOptionDescription @@ -128,8 +101,6 @@ public PsCommand( _logger = logger; Options.Add(s_formatOption); - Options.Add(s_resourcesOption); - Options.Add(s_includeHiddenOption); Options.Add(s_followOption); } @@ -138,12 +109,10 @@ protected override async Task ExecuteAsync(ParseResult parseResul using var activity = Telemetry.StartDiagnosticActivity(Name); var format = parseResult.GetValue(s_formatOption); - var includeResources = parseResult.GetValue(s_resourcesOption); - var includeHidden = parseResult.GetValue(s_includeHiddenOption); if (parseResult.GetValue(s_followOption)) { - return await ExecuteFollowAsync(format, includeResources, includeHidden, cancellationToken).ConfigureAwait(false); + return await ExecuteFollowAsync(format, cancellationToken).ConfigureAwait(false); } // Scan for running AppHosts (same as ListAppHostsTool). JSON output must not go @@ -174,7 +143,7 @@ protected override async Task ExecuteAsync(ParseResult parseResul .ToList(); // Gather info for each AppHost - var appHostInfos = await GatherAppHostInfosAsync(orderedConnections, includeResources && format == OutputFormat.Json, includeHidden, cancellationToken).ConfigureAwait(false); + var appHostInfos = await GatherAppHostInfosAsync(orderedConnections, cancellationToken).ConfigureAwait(false); if (format == OutputFormat.Json) { @@ -201,9 +170,7 @@ private abstract record PsFollowUpdate; private sealed record ConnectionsUpdate(IReadOnlyList Connections) : PsFollowUpdate; - private sealed record ResourceUpdate(IAppHostAuxiliaryBackchannel Connection) : PsFollowUpdate; - - private async Task ExecuteFollowAsync(OutputFormat format, bool includeResources, bool includeHidden, CancellationToken cancellationToken) + private async Task ExecuteFollowAsync(OutputFormat format, CancellationToken cancellationToken) { if (format != OutputFormat.Json) { @@ -215,12 +182,9 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i { SingleReader = true }); - var currentConnections = new List(); var appHostKeyComparer = GetAppHostKeyComparer(); var activeAppHosts = new Dictionary(appHostKeyComparer); var lastJsonByAppHost = new Dictionary(appHostKeyComparer); - CancellationTokenSource? resourceWatchCts = null; - List resourceWatchTasks = []; _ = Task.Run(async () => { @@ -251,10 +215,8 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i { if (update is ConnectionsUpdate connectionsUpdate) { - currentConnections = OrderConnections(connectionsUpdate.Connections); - await RestartResourceWatchersAsync().ConfigureAwait(false); - - var currentAppHosts = await GatherAppHostInfosAsync(currentConnections, includeResources, includeHidden, followCancellationToken).ConfigureAwait(false); + var currentConnections = OrderConnections(connectionsUpdate.Connections); + var currentAppHosts = await GatherAppHostInfosAsync(currentConnections, followCancellationToken).ConfigureAwait(false); var nextActiveAppHosts = new Dictionary(appHostKeyComparer); foreach (var appHost in currentAppHosts) @@ -277,27 +239,6 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i activeAppHosts = nextActiveAppHosts; } - else if (update is ResourceUpdate resourceUpdate) - { - var appHostInfos = await GatherAppHostInfosAsync([resourceUpdate.Connection], includeResources, includeHidden, followCancellationToken).ConfigureAwait(false); - var appHost = appHostInfos.SingleOrDefault(); - if (appHost is null) - { - continue; - } - - var key = GetAppHostKey(appHost); - if (!activeAppHosts.ContainsKey(key)) - { - continue; - } - - activeAppHosts[key] = appHost; - if (!await TryWriteAppHostInfoAsync(appHost).ConfigureAwait(false)) - { - return CommandResult.Success(); - } - } } } catch (OperationCanceledException) when (followCancellationToken.IsCancellationRequested) @@ -312,57 +253,10 @@ private async Task ExecuteFollowAsync(OutputFormat format, bool i finally { await followCancellationTokenSource.CancelAsync().ConfigureAwait(false); - if (resourceWatchCts is not null) - { - await resourceWatchCts.CancelAsync().ConfigureAwait(false); - await Task.WhenAll(resourceWatchTasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - resourceWatchCts.Dispose(); - } } return CommandResult.Success(); - async Task RestartResourceWatchersAsync() - { - if (resourceWatchCts is not null) - { - await resourceWatchCts.CancelAsync().ConfigureAwait(false); - await Task.WhenAll(resourceWatchTasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); - resourceWatchCts.Dispose(); - resourceWatchCts = null; - resourceWatchTasks = []; - } - - if (!includeResources || format != OutputFormat.Json) - { - return; - } - - resourceWatchCts = CancellationTokenSource.CreateLinkedTokenSource(followCancellationToken); - var resourceCancellationToken = resourceWatchCts.Token; - foreach (var connection in currentConnections) - { - resourceWatchTasks.Add(Task.Run(async () => - { - try - { - await foreach (var _ in connection.WatchResourceSnapshotsAsync(includeHidden, resourceCancellationToken).WithCancellation(resourceCancellationToken).ConfigureAwait(false)) - { - await updates.Writer.WriteAsync(new ResourceUpdate(connection), resourceCancellationToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) when (resourceCancellationToken.IsCancellationRequested) - { - // Expected when the connection list changes or the command stops. - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed while watching resource snapshots for {AppHostPath}.", connection.AppHostInfo?.AppHostPath); - } - }, CancellationToken.None)); - } - } - async Task TryWriteAppHostInfoAsync(AppHostDisplayInfo appHost) { var key = GetAppHostKey(appHost); @@ -414,8 +308,7 @@ private static AppHostDisplayInfo CopyWithStatus(AppHostDisplayInfo appHost, str SdkVersion = appHost.SdkVersion, CliPid = appHost.CliPid, DashboardUrl = appHost.DashboardUrl, - LogFilePath = appHost.LogFilePath, - Resources = appHost.Resources + LogFilePath = appHost.LogFilePath }; } @@ -426,7 +319,7 @@ private static List OrderConnections(IEnumerable> GatherAppHostInfosAsync(List connections, bool includeResources, bool includeHidden, CancellationToken cancellationToken) + private async Task> GatherAppHostInfosAsync(List connections, CancellationToken cancellationToken) { var appHostInfos = new List(); @@ -480,20 +373,6 @@ private async Task> GatherAppHostInfosAsync(List? resources = null; - if (includeResources) - { - try - { - var snapshots = await connection.GetResourceSnapshotsAsync(includeHidden, cancellationToken).ConfigureAwait(false); - resources = ResourceSnapshotMapper.MapToResourceJsonList(snapshots, dashboardUrl, includeEnvironmentVariableValues: false); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Failed to get resource snapshots for {AppHostPath}", info.AppHostPath); - } - } - appHostInfos.Add(new AppHostDisplayInfo { AppHostPath = appHostPath ?? PsCommandStrings.UnknownPath, @@ -502,8 +381,7 @@ private async Task> GatherAppHostInfosAsync(List Unknown - - Include resource details for each running AppHost. Only applies to JSON output. - - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. The --follow option only supports JSON output. Use --format json. diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf index 3caecff13d0..d16e817de7d 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.cs.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Výstup ve formátu JSON - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Neznámé diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf index b72fb405eaa..302c834e8f2 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.de.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Ausgabe im JSON-Format. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Unbekannt diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf index ce2f85d6b41..0c4df433772 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.es.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Salida en formato JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Desconocido diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf index f10c909806d..8aa4be8f43d 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.fr.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Sortie au format JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Inconnu diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf index cec06e8f1c7..10349c21b98 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.it.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Output in formato JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Sconosciuto diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf index 398bfc4972b..f59b502a801 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ja.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ JSON 形式で出力します。 - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown 不明 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf index 0d78643506b..b5eb897baeb 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ko.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ JSON 형식의 출력입니다. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown 알 수 없음 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf index f078f83fe46..ac3b47aad3e 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pl.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Wynik w formacie JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Nieznane diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf index dc6d95aab59..7ca49e6015d 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.pt-BR.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Saída no formato JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Desconhecido diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf index 6a8d805b322..e726bd4836c 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.ru.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Вывод в формате JSON. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Неизвестно diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf index 613a8aaec81..8e8c7059cc5 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.tr.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ Çıkışı JSON biçiminde oluşturun. - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown Bilinmiyor diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf index d9bf1dea07b..f63554406d8 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hans.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ 以 JSON 格式输出。 - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown 未知 diff --git a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf index 0ce98849920..bda1455aa72 100644 --- a/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/PsCommandStrings.zh-Hant.xlf @@ -8,8 +8,8 @@ - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. - Keep running and emit updates as running AppHosts or resources change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. + Keep running and emit updates as running AppHosts change. JSON output is emitted as newline-delimited full snapshots. @@ -47,11 +47,6 @@ 以 JSON 格式輸出。 - - Include resource details for each running AppHost. Only applies to JSON output. - Include resource details for each running AppHost. Only applies to JSON output. - - Unknown 未知 diff --git a/src/Shared/Model/Serialization/ResourceJson.cs b/src/Shared/Model/Serialization/ResourceJson.cs index 5212484429e..352d11a6c73 100644 --- a/src/Shared/Model/Serialization/ResourceJson.cs +++ b/src/Shared/Model/Serialization/ResourceJson.cs @@ -10,8 +10,8 @@ namespace Aspire.Shared.Model.Serialization; /// Represents a resource in JSON format. /// This is a shared representation used by both the Dashboard and CLI. /// -// CLI commands such as `aspire describe --format json` and `aspire ps --format json --resources` -// use this shape and the nested resource shapes below; keep docs/specs/cli-output-formats.md in sync when changing them. +// `aspire describe --format json` uses this shape and the nested resource shapes below; +// keep docs/specs/cli-output-formats.md in sync when changing them. internal sealed class ResourceJson { /// diff --git a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs index f4fc2c0919a..31ff6ec3e68 100644 --- a/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/PsCommandTests.cs @@ -3,7 +3,6 @@ using System.Net; using System.Net.Sockets; -using System.Runtime.CompilerServices; using System.Text.Json; using Aspire.Cli.Backchannel; using Aspire.Cli.Commands; @@ -501,7 +500,7 @@ public async Task PsCommand_JsonFormat_DoesNotShowScanningStatus() using var provider = services.BuildServiceProvider(); var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources"); + var result = command.Parse("ps --format json"); var exitCode = await result.InvokeAsync().DefaultTimeout(); @@ -513,311 +512,6 @@ public async Task PsCommand_JsonFormat_DoesNotShowScanningStatus() Assert.Equal(JsonValueKind.Array, document.RootElement.ValueKind); } - [Fact] - public async Task PsCommand_ResourcesOption_IncludesResourcesInJsonOutput() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var textWriter = new TestOutputTextWriter(outputHelper); - - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234, - CliProcessId = 5678 - }, - DashboardUrlsState = new DashboardUrlsState - { - BaseUrlWithLoginToken = "http://localhost:18888/login?t=abc123" - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running", - StateStyle = "success", - Urls = - [ - new ResourceSnapshotUrl { Name = "https", Url = "https://localhost:7001" } - ] - }, - new ResourceSnapshot - { - Name = "redis", - DisplayName = "redis", - ResourceType = "Container", - State = "Running", - StateStyle = "success" - }, - new ResourceSnapshot - { - Name = "aspire-dashboard", - DisplayName = "aspire-dashboard", - ResourceType = "Project", - State = "Hidden", - IsHidden = true - } - ] - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - - var jsonOutput = string.Join(string.Empty, textWriter.Logs); - var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo); - Assert.NotNull(appHosts); - Assert.Single(appHosts); - - var appHost = appHosts[0]; - Assert.NotNull(appHost.Resources); - Assert.Equal(2, appHost.Resources.Count); - - var apiService = appHost.Resources.First(r => r.Name == "apiservice"); - Assert.Equal("Project", apiService.ResourceType); - Assert.Equal("Running", apiService.State); - Assert.NotNull(apiService.Urls); - Assert.Single(apiService.Urls); - Assert.Equal("https://localhost:7001", apiService.Urls[0].Url); - - var redis = appHost.Resources.First(r => r.Name == "redis"); - Assert.Equal("Container", redis.ResourceType); - Assert.Equal("Running", redis.State); - Assert.DoesNotContain(appHost.Resources, resource => resource.Name == "aspire-dashboard"); - } - - [Fact] - public async Task PsCommand_ResourcesOption_WithIncludeHidden_IncludesHiddenResources() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var textWriter = new TestOutputTextWriter(outputHelper); - - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234 - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running" - }, - new ResourceSnapshot - { - Name = "aspire-dashboard", - DisplayName = "aspire-dashboard", - ResourceType = "Project", - State = "Hidden", - IsHidden = true - } - ] - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources --include-hidden"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - - var jsonOutput = string.Join(string.Empty, textWriter.Logs); - var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo); - Assert.NotNull(appHosts); - var resources = Assert.Single(appHosts).Resources; - Assert.NotNull(resources); - Assert.Contains(resources, resource => resource.Name == "apiservice"); - Assert.Contains(resources, resource => resource.Name == "aspire-dashboard"); - } - - [Theory] - [InlineData(false, 1)] - [InlineData(true, 2)] - public async Task PsCommand_FollowResourcesOption_HandlesHiddenResources(bool includeHidden, int expectedResourceCount) - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - using var cancellationTokenSource = new CancellationTokenSource(); - var outputLines = new List(); - var textWriter = new TestOutputTextWriter(outputHelper, line => - { - outputLines.Add(line); - cancellationTokenSource.Cancel(); - }); - - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234 - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running" - }, - new ResourceSnapshot - { - Name = "aspire-dashboard", - DisplayName = "aspire-dashboard", - ResourceType = "Project", - State = "Hidden", - IsHidden = true - } - ] - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var includeHiddenArg = includeHidden ? " --include-hidden" : string.Empty; - var result = command.Parse($"ps --format json --resources --follow{includeHiddenArg}"); - - var exitCode = await result.InvokeAsync(cancellationToken: cancellationTokenSource.Token).DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - var outputLine = Assert.Single(outputLines); - var appHost = JsonSerializer.Deserialize(outputLine, PsCommandJsonContext.RelaxedEscaping.AppHostDisplayInfo); - Assert.NotNull(appHost); - Assert.Equal(AppHostDisplayStatus.Running, appHost.Status); - var resources = appHost.Resources; - Assert.NotNull(resources); - Assert.Equal(expectedResourceCount, resources.Count); - Assert.Contains(resources, resource => resource.Name == "apiservice"); - Assert.Equal(includeHidden, resources.Any(resource => resource.Name == "aspire-dashboard")); - } - - [Fact] - public async Task PsCommand_FollowJsonFormat_StreamsChangedAppHostForResourceUpdates() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - using var cancellationTokenSource = new CancellationTokenSource(); - var outputLines = new List(); - var updateResource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var textWriter = new TestOutputTextWriter(outputHelper, line => - { - outputLines.Add(line); - if (outputLines.Count == 1) - { - updateResource.SetResult(); - } - else if (outputLines.Count == 2) - { - cancellationTokenSource.Cancel(); - } - }); - - var snapshots = new List - { - new() - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Starting", - StateStyle = "info" - } - }; - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234, - CliProcessId = 5678 - }, - GetResourceSnapshotsHandler = _ => Task.FromResult(snapshots.ToList()), - WatchResourceSnapshotsHandler = WatchResourceSnapshotsAsync - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources --follow"); - - var exitCode = await result.InvokeAsync(cancellationToken: cancellationTokenSource.Token).DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - Assert.Equal(2, outputLines.Count); - - var initialAppHost = JsonSerializer.Deserialize(outputLines[0], PsCommandJsonContext.RelaxedEscaping.AppHostDisplayInfo); - var updatedAppHost = JsonSerializer.Deserialize(outputLines[1], PsCommandJsonContext.RelaxedEscaping.AppHostDisplayInfo); - Assert.NotNull(initialAppHost); - Assert.NotNull(updatedAppHost); - Assert.Equal(AppHostDisplayStatus.Running, initialAppHost.Status); - Assert.Equal(AppHostDisplayStatus.Running, updatedAppHost.Status); - Assert.Equal("Starting", Assert.Single(initialAppHost.Resources!).State); - Assert.Equal("Running", Assert.Single(updatedAppHost.Resources!).State); - - async IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken) - { - Assert.False(includeHidden); - await updateResource.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - snapshots[0] = new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running", - StateStyle = "success" - }; - yield return snapshots[0]; - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); - } - } - [Fact] public async Task PsCommand_FollowJsonFormat_ReturnsSuccessWhenOutputCloses() { @@ -854,79 +548,6 @@ public async Task PsCommand_FollowJsonFormat_ReturnsSuccessWhenOutputCloses() Assert.Single(interactionService.DisplayedRawText); } - [Fact] - public async Task PsCommand_FollowJsonFormat_WaitsForResourceWatchersBeforeReturning() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - using var cancellationTokenSource = new CancellationTokenSource(); - var watcherCleanupStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var allowWatcherCleanup = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var textWriter = new TestOutputTextWriter(outputHelper, _ => cancellationTokenSource.Cancel()); - var monitor = new TestAuxiliaryBackchannelMonitor(); - monitor.AddConnection("hash1", "socket.hash1", new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234, - CliProcessId = 5678 - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - DisplayName = "apiservice", - ResourceType = "Project", - State = "Running" - } - ], - WatchResourceSnapshotsHandler = WatchResourceSnapshotsAsync - }); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json --resources --follow"); - - var invokeTask = result.InvokeAsync(cancellationToken: cancellationTokenSource.Token); - - await watcherCleanupStarted.Task.DefaultTimeout(); - try - { - Assert.False(invokeTask.IsCompleted, "The command returned before its resource watcher task completed cleanup."); - } - finally - { - allowWatcherCleanup.TrySetResult(); - } - - var exitCode = await invokeTask.DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - - async IAsyncEnumerable WatchResourceSnapshotsAsync(bool includeHidden, [EnumeratorCancellation] CancellationToken cancellationToken) - { - try - { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); - } - finally - { - watcherCleanupStarted.TrySetResult(); - await allowWatcherCleanup.Task.ConfigureAwait(false); - } - - yield break; - } - } - [Fact] public async Task PsCommand_FollowWithoutJsonFormat_ReturnsInvalidCommand() { @@ -1001,103 +622,6 @@ public async Task PsCommand_FollowJsonFormat_StreamsStoppedAppHostWhenConnection Assert.Equal(initialAppHost.AppHostPid, stoppedAppHost.AppHostPid); } - [Fact] - public async Task PsCommand_WithoutResourcesOption_OmitsResourcesFromJsonOutput() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var textWriter = new TestOutputTextWriter(outputHelper); - - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234 - }, - ResourceSnapshots = - [ - new ResourceSnapshot - { - Name = "apiservice", - ResourceType = "Project", - State = "Running" - } - ] - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - var result = command.Parse("ps --format json"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - - var jsonOutput = string.Join(string.Empty, textWriter.Logs); - var appHosts = JsonSerializer.Deserialize(jsonOutput, PsCommandJsonContext.RelaxedEscaping.ListAppHostDisplayInfo); - Assert.NotNull(appHosts); - Assert.Single(appHosts); - Assert.Null(appHosts[0].Resources); - - // Also verify the raw JSON doesn't contain a "resources" key - var document = JsonDocument.Parse(jsonOutput); - var firstElement = document.RootElement[0]; - Assert.False(firstElement.TryGetProperty("resources", out _)); - } - - [Fact] - public async Task PsCommand_ResourcesOption_TableFormat_DoesNotFetchResources() - { - using var workspace = TemporaryWorkspace.Create(outputHelper); - var textWriter = new TestOutputTextWriter(outputHelper); - - var resourcesFetched = false; - var monitor = new TestAuxiliaryBackchannelMonitor(); - var connection = new TestAppHostAuxiliaryBackchannel - { - IsInScope = true, - AppHostInfo = new AppHostInformation - { - AppHostPath = Path.Combine(workspace.WorkspaceRoot.FullName, "App1", "App1.AppHost.csproj"), - ProcessId = 1234 - }, - GetResourceSnapshotsHandler = _ => - { - resourcesFetched = true; - return Task.FromResult(new List - { - new ResourceSnapshot { Name = "apiservice", ResourceType = "Project", State = "Running" } - }); - } - }; - monitor.AddConnection("hash1", "socket.hash1", connection); - - var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => - { - options.OutputTextWriter = textWriter; - options.AuxiliaryBackchannelMonitorFactory = _ => monitor; - }); - using var provider = services.BuildServiceProvider(); - - var command = provider.GetRequiredService(); - // --resources with table format should not fetch resources - var result = command.Parse("ps --resources"); - - var exitCode = await result.InvokeAsync().DefaultTimeout(); - - Assert.Equal(CliExitCodes.Success, exitCode); - Assert.False(resourcesFetched, "Resources should not be fetched when output format is table"); - } - [Fact] public async Task PsCommand_JsonFormat_IncludesLogFilePath() {