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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions docs/plans/project-v2-csharpprogram-watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ predicate; fallback: a dedicated prepare path or a distinct launch type). Preser
**Verify:** F5/debug of a `DotnetProjectResource` (no watch) from a C# app host and the Aspire VS Code extension;
debug behavior matches `AddProject`. *Depends on: 1.* **(R1)**

**Status: ⚠️ Implementation complete; manual F5 verification not recorded.** DCP now treats an
**Status: ✅ Complete.** DCP now treats an
`ExecutableResource` carrying `IProjectMetadata` and `"project"` debug support as a project launch.
`DotnetProjectResource` therefore gets launch-profile and Debug/NoDebug handling, is classified/rendered as a
project, and passes only application arguments to the IDE. Project launches and other debug integrations that
Expand All @@ -308,14 +308,27 @@ disabled launch profile authoritative for application arguments, working directo
preserving required `DOTNET_ROOT*` host variables.

Automated coverage exercises the DCP, package, snapshot, and extension behavior above. The C# app-host + VS Code
F5 check in **Verify** remains unconfirmed. *(Watch-mode debugging remains out of scope; see Session 9.)*
F5 check in **Verify** was verified manually. *(Watch-mode debugging remains out of scope; see Session 9.)*

### Session 3 — Core run sub-mode state (minimal, no mechanics)
Add `IsWatch`/`RunSubMode` to `DistributedApplicationExecutionContext(+Options)`; populate from a CLI config
key in `DistributedApplicationBuilder` (mirror `Publishing:Publisher`). No watch logic in core. **Verify:**
unit test that the config flag flips `ExecutionContext.IsWatch`; both run modes still behave normally.
*Depends on: none (parallelizable with 1–2).*

**Status: ✅ Complete.** Core now exposes an experimental `RunSubMode { Normal, Watch }` enum and a read-only
`DistributedApplicationExecutionContext.RunSubMode` (plus an `init` `RunSubMode` on
`DistributedApplicationExecutionContextOptions`), all marked
`[Experimental("ASPIREWATCH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]`.
**Shape decision: enum only — no `IsWatch` bool** (downstream reads `ExecutionContext.RunSubMode == RunSubMode.Watch`).
`DistributedApplicationBuilder.BuildExecutionContextOptions()` populates the sub-mode on both
run-mode paths from the **`AppHost:RunSubMode`** configuration key (mirrors `AppHost:Operation`), matched
case-insensitively against the declared `RunSubMode` names; numeric, compound, unknown, or missing values fall
back to `Normal`, and publish mode always reports `Normal`. Core contains **no** watch mechanics; the CLI `aspire run --watch` → `AppHost:RunSubMode`
bridge is layered on in Session 7. Added `OperationModesTests` cases (default `Normal`, `Watch` from config,
case-insensitive, unknown→`Normal`, publish→`Normal`) and regenerated the five polyglot codegen snapshots
(TS/Go/Python/Java/Rust — additive `RunSubMode` enum + `runSubMode()` getter only).

### Session 4 — Watch tool acquisition in `Aspire.Hosting.Dotnet`
Pin + mirror `Microsoft.DotNet.HotReload.Watch.Aspire` (A2); add the `PackageReference` +
`GeneratePathProperty` + `.targets` assembly-metadata injection + runtime resolver; invoke via `dotnet exec`.
Expand Down Expand Up @@ -416,7 +429,10 @@ callbacks may need to run for build/closure even when a resource isn't "running"
- **R9 — Blazor gateway variant (Session 1).** The new `DotnetProjectResource`-backed gateway shares one
generalized helper implementation with the unchanged `ProjectResource` gateway; publish on the new variant
fails fast until `DotnetProjectResource` gains container-files support.
- **O1 — Run sub-mode shape.** `bool IsWatch` vs a `RunSubMode` enum (future-proof for more sub-modes).
- **O1 — Run sub-mode shape.** ✅ **Resolved (Session 3): enum only** — a `RunSubMode { Normal, Watch }` enum
and a read-only `DistributedApplicationExecutionContext.RunSubMode`, **no `IsWatch` bool** (future-proof for
more sub-modes). Sessions 6/7 must query `ExecutionContext.RunSubMode == RunSubMode.Watch`. The signal is
carried by the `AppHost:RunSubMode` configuration key.
- **O2 — App-host watch for polyglot hosts (Session 8).** How the tool's `host` command applies when the app
host is TypeScript/Go/Python (guest runtime watch vs the C# app-host-server).

Expand Down
15 changes: 13 additions & 2 deletions src/Aspire.Hosting/DistributedApplicationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#pragma warning disable ASPIRECONTAINERRUNTIME001
#pragma warning disable ASPIREFILESYSTEM001
#pragma warning disable ASPIREUSERSECRETS001
#pragma warning disable ASPIREWATCH001

using System.Diagnostics;
using System.Reflection;
Expand Down Expand Up @@ -141,7 +142,7 @@ private DistributedApplicationExecutionContextOptions BuildExecutionContextOptio
return _innerBuilder.Configuration["Publishing:Publisher"] switch
{
{ } publisher => new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Publish, publisher),
_ => new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run)
_ => new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { RunSubMode = ParseRunSubMode() }
};
}

Expand All @@ -154,12 +155,22 @@ private DistributedApplicationExecutionContextOptions BuildExecutionContextOptio

return operation switch
{
DistributedApplicationOperation.Run => new DistributedApplicationExecutionContextOptions(operation),
DistributedApplicationOperation.Run => new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Run) { RunSubMode = ParseRunSubMode() },
DistributedApplicationOperation.Publish => new DistributedApplicationExecutionContextOptions(operation, _innerBuilder.Configuration["Publishing:Publisher"] ?? "manifest"),
_ => throw new DistributedApplicationException("Invalid operation specified. Valid operations are 'publish' or 'run'.")
};
}

private RunSubMode ParseRunSubMode()
{
// Match only the declared enum names, case-insensitively. Enum.TryParse is intentionally avoided
// because it also succeeds for inputs that are not declared members, which would bypass the
// documented unknown-value -> Normal fallback and leak an unsupported sub-mode to integrations.
return string.Equals(_innerBuilder.Configuration["AppHost:RunSubMode"], nameof(RunSubMode.Watch), StringComparison.OrdinalIgnoreCase)
? RunSubMode.Watch
: RunSubMode.Normal;
}

/// <summary>
/// Initializes a new instance of the <see cref="DistributedApplicationBuilder"/> class with the specified options.
/// </summary>
Expand Down
12 changes: 12 additions & 0 deletions src/Aspire.Hosting/DistributedApplicationExecutionContext.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;

namespace Aspire.Hosting;

/// <summary>
Expand Down Expand Up @@ -48,13 +50,23 @@ public DistributedApplicationExecutionContext(DistributedApplicationOperation op
public DistributedApplicationExecutionContext(DistributedApplicationExecutionContextOptions options) : this(options.Operation, options.PublisherName ?? "manifest")
{
_options = options;
#pragma warning disable ASPIREWATCH001 // RunSubMode is experimental; core populates it from the options.
RunSubMode = options.Operation == DistributedApplicationOperation.Run ? options.RunSubMode : RunSubMode.Normal;
#pragma warning restore ASPIREWATCH001
}

/// <summary>
/// The operation currently being performed by the AppHost.
/// </summary>
public DistributedApplicationOperation Operation { get; }

/// <summary>
/// The run sub-mode the AppHost is running under. Only meaningful when <see cref="Operation"/> is
/// <see cref="DistributedApplicationOperation.Run"/>; otherwise <see cref="RunSubMode.Normal"/>.
/// </summary>
[Experimental("ASPIREWATCH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public RunSubMode RunSubMode { get; }

/// <summary>
/// The <see cref="IServiceProvider"/> for the AppHost.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;

namespace Aspire.Hosting;

/// <summary>
Expand Down Expand Up @@ -54,4 +56,11 @@ public IServiceProvider? ServiceProvider
/// The name of the publisher if running in publish mode.
/// </summary>
public string? PublisherName { get; }

/// <summary>
/// The run sub-mode the AppHost is running under. Only meaningful when <see cref="Operation"/> is
/// <see cref="DistributedApplicationOperation.Run"/>; defaults to <see cref="RunSubMode.Normal"/>.
/// </summary>
[Experimental("ASPIREWATCH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public RunSubMode RunSubMode { get; init; } = RunSubMode.Normal;
}
32 changes: 32 additions & 0 deletions src/Aspire.Hosting/RunSubMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;

namespace Aspire.Hosting;

/// <summary>
/// Describes the run sub-mode the AppHost is running under (when
/// <see cref="DistributedApplicationExecutionContext.Operation"/> is
/// <see cref="DistributedApplicationOperation.Run"/>).
/// </summary>
/// <remarks>
/// The run sub-mode is populated from configuration by the AppHost builder and surfaced through
/// <see cref="DistributedApplicationExecutionContext.RunSubMode"/>.
/// It lets integrations vary how their resources are launched without changing the core hosting behavior.
/// In <see cref="DistributedApplicationOperation.Publish"/> mode the sub-mode is always <see cref="Normal"/>.
/// </remarks>
[Experimental("ASPIREWATCH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
public enum RunSubMode
{
/// <summary>
/// The AppHost is running normally. Resources are launched using their standard run behavior.
/// </summary>
Normal,

/// <summary>
/// The AppHost is running in watch sub-mode. Integrations that support watch
/// can launch their resources so that source changes are hot-reloaded.
/// </summary>
Watch,
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ const (
DistributedApplicationOperationPublish DistributedApplicationOperation = "Publish"
)

// RunSubMode represents RunSubMode.
type RunSubMode string

const (
RunSubModeNormal RunSubMode = "Normal"
RunSubModeWatch RunSubMode = "Watch"
)

// OtlpProtocol represents OtlpProtocol.
type OtlpProtocol string

Expand Down Expand Up @@ -10431,6 +10439,7 @@ type DistributedApplicationExecutionContext interface {
IsRunMode() (bool, error)
Operation() (DistributedApplicationOperation, error)
PublisherName() (string, error)
RunSubMode() (RunSubMode, error)
ServiceProvider() ServiceProvider
Services() ServiceProvider
SetPublisherName(value string) DistributedApplicationExecutionContext
Expand Down Expand Up @@ -10507,6 +10516,21 @@ func (s *distributedApplicationExecutionContext) PublisherName() (string, error)
return decodeAs[string](result)
}

// RunSubMode the run sub-mode the AppHost is running under. Only meaningful when `Operation` is `Run`; otherwise `Normal`.
func (s *distributedApplicationExecutionContext) RunSubMode() (RunSubMode, error) {
if s.err != nil { var zero RunSubMode; return zero, s.err }
ctx := context.Background()
reqArgs := map[string]any{
"context": s.handle.ToJSON(),
}
result, err := s.client.invokeCapability(ctx, "Aspire.Hosting/DistributedApplicationExecutionContext.runSubMode", reqArgs)
if err != nil {
var zero RunSubMode
return zero, err
}
return decodeAs[RunSubMode](result)
}

// ServiceProvider the `IServiceProvider` for the AppHost.
func (s *distributedApplicationExecutionContext) ServiceProvider() ServiceProvider {
if s.err != nil { return &serviceProvider{resourceBuilderBase: newErroredResourceBuilder(s.err, s.client)} }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7947,6 +7947,14 @@ public DistributedApplicationOperation operation() {
return DistributedApplicationOperation.fromValue((String) result);
}

/** The run sub-mode the AppHost is running under. Only meaningful when `Operation` is `Run`; otherwise `Normal`. */
public RunSubMode runSubMode() {
Map<String, Object> reqArgs = new HashMap<>();
reqArgs.put("context", AspireClient.serializeValue(getHandle()));
var result = getClient().invokeCapability("Aspire.Hosting/DistributedApplicationExecutionContext.runSubMode", reqArgs);
return RunSubMode.fromValue((String) result);
}

/** The `IServiceProvider` for the AppHost. */
public IServiceProvider serviceProvider() {
Map<String, Object> reqArgs = new HashMap<>();
Expand Down Expand Up @@ -21663,6 +21671,35 @@ public void addForEndpoint(EndpointReference endpoint, AspireUnion url, String d

}

// ===== RunSubMode.java =====
// RunSubMode.java - GENERATED CODE - DO NOT EDIT

package aspire;

import java.util.*;
import java.util.function.*;

/** RunSubMode enum. */
public enum RunSubMode implements WireValueEnum {
NORMAL("Normal"),
WATCH("Watch");

private final String value;

RunSubMode(String value) {
this.value = value;
}

public String getValue() { return value; }

public static RunSubMode fromValue(String value) {
for (RunSubMode e : values()) {
if (e.value.equals(value)) return e;
}
throw new IllegalArgumentException("Unknown value: " + value);
}
}

// ===== TestCallbackContext.java =====
// TestCallbackContext.java - GENERATED CODE - DO NOT EDIT

Expand Down Expand Up @@ -29281,6 +29318,7 @@ public WithVolumeOptions isReadOnly(Boolean value) {
.aspire/modules/ResourceUrlAnnotation.java
.aspire/modules/ResourceUrlsCallbackContext.java
.aspire/modules/ResourceUrlsEditor.java
.aspire/modules/RunSubMode.java
.aspire/modules/TestCallbackContext.java
.aspire/modules/TestCollectionContext.java
.aspire/modules/TestConfigDto.java
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1544,6 +1544,8 @@ def _validate_dict_types(args: typing.Any, arg_types: typing.Any) -> bool:

ResourceCommandVisibility = typing.Literal["None", "UI", "Api"]

RunSubMode = typing.Literal["Normal", "Watch"]

TestPersistenceMode = typing.Literal["None", "Volume", "Bind"]

TestResourceStatus = typing.Literal["Pending", "Running", "Stopped", "Failed"]
Expand Down Expand Up @@ -4409,6 +4411,15 @@ def operation(self) -> DistributedApplicationOperation:
)
return typing.cast(DistributedApplicationOperation, result)

@_cached_property
def run_sub_mode(self) -> RunSubMode:
"""The run sub-mode the AppHost is running under. Only meaningful when `Operation` is `Run`; otherwise `Normal`."""
result = self._client.invoke_capability(
'Aspire.Hosting/DistributedApplicationExecutionContext.runSubMode',
{'context': self._handle}
)
return typing.cast(RunSubMode, result)

@_cached_property
def service_provider(self) -> AbstractServiceProvider:
"""The `IServiceProvider` for the AppHost."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,25 @@ impl std::fmt::Display for DistributedApplicationOperation {
}
}

/// RunSubMode
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum RunSubMode {
#[default]
#[serde(rename = "Normal")]
Normal,
#[serde(rename = "Watch")]
Watch,
}

impl std::fmt::Display for RunSubMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Normal => write!(f, "Normal"),
Self::Watch => write!(f, "Watch"),
}
}
}

/// OtlpProtocol
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum OtlpProtocol {
Expand Down Expand Up @@ -6438,6 +6457,14 @@ impl DistributedApplicationExecutionContext {
Ok(serde_json::from_value(result)?)
}

/// The run sub-mode the AppHost is running under. Only meaningful when `Operation` is `Run`; otherwise `Normal`.
pub fn run_sub_mode(&self) -> Result<RunSubMode, Box<dyn std::error::Error>> {
let mut args: HashMap<String, Value> = HashMap::new();
args.insert("context".to_string(), self.handle.to_json());
let result = self.client.invoke_capability("Aspire.Hosting/DistributedApplicationExecutionContext.runSubMode", args)?;
Ok(serde_json::from_value(result)?)
}

/// The `IServiceProvider` for the AppHost.
pub fn service_provider(&self) -> Result<IServiceProvider, Box<dyn std::error::Error>> {
let mut args: HashMap<String, Value> = HashMap::new();
Expand Down
Loading
Loading