diff --git a/docs/plans/project-v2-csharpprogram-watch.md b/docs/plans/project-v2-csharpprogram-watch.md
index e82b00937dd..7cb170bd8c0 100644
--- a/docs/plans/project-v2-csharpprogram-watch.md
+++ b/docs/plans/project-v2-csharpprogram-watch.md
@@ -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
@@ -308,7 +308,7 @@ 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
@@ -316,6 +316,19 @@ key in `DistributedApplicationBuilder` (mirror `Publishing:Publisher`). No watch
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`.
@@ -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).
diff --git a/src/Aspire.Hosting/DistributedApplicationBuilder.cs b/src/Aspire.Hosting/DistributedApplicationBuilder.cs
index 02f00deeda0..f0136a885d4 100644
--- a/src/Aspire.Hosting/DistributedApplicationBuilder.cs
+++ b/src/Aspire.Hosting/DistributedApplicationBuilder.cs
@@ -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;
@@ -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() }
};
}
@@ -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;
+ }
+
///
/// Initializes a new instance of the class with the specified options.
///
diff --git a/src/Aspire.Hosting/DistributedApplicationExecutionContext.cs b/src/Aspire.Hosting/DistributedApplicationExecutionContext.cs
index a5a526a40a0..33c9b085cdb 100644
--- a/src/Aspire.Hosting/DistributedApplicationExecutionContext.cs
+++ b/src/Aspire.Hosting/DistributedApplicationExecutionContext.cs
@@ -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;
///
@@ -48,6 +50,9 @@ 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
}
///
@@ -55,6 +60,13 @@ public DistributedApplicationExecutionContext(DistributedApplicationExecutionCon
///
public DistributedApplicationOperation Operation { get; }
+ ///
+ /// The run sub-mode the AppHost is running under. Only meaningful when is
+ /// ; otherwise .
+ ///
+ [Experimental("ASPIREWATCH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ public RunSubMode RunSubMode { get; }
+
///
/// The for the AppHost.
///
diff --git a/src/Aspire.Hosting/DistributedApplicationExecutionContextOptions.cs b/src/Aspire.Hosting/DistributedApplicationExecutionContextOptions.cs
index 05aae04db4a..3bbbcabb77f 100644
--- a/src/Aspire.Hosting/DistributedApplicationExecutionContextOptions.cs
+++ b/src/Aspire.Hosting/DistributedApplicationExecutionContextOptions.cs
@@ -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;
///
@@ -54,4 +56,11 @@ public IServiceProvider? ServiceProvider
/// The name of the publisher if running in publish mode.
///
public string? PublisherName { get; }
+
+ ///
+ /// The run sub-mode the AppHost is running under. Only meaningful when is
+ /// ; defaults to .
+ ///
+ [Experimental("ASPIREWATCH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+ public RunSubMode RunSubMode { get; init; } = RunSubMode.Normal;
}
diff --git a/src/Aspire.Hosting/RunSubMode.cs b/src/Aspire.Hosting/RunSubMode.cs
new file mode 100644
index 00000000000..bf9e903754f
--- /dev/null
+++ b/src/Aspire.Hosting/RunSubMode.cs
@@ -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;
+
+///
+/// Describes the run sub-mode the AppHost is running under (when
+/// is
+/// ).
+///
+///
+/// The run sub-mode is populated from configuration by the AppHost builder and surfaced through
+/// .
+/// It lets integrations vary how their resources are launched without changing the core hosting behavior.
+/// In mode the sub-mode is always .
+///
+[Experimental("ASPIREWATCH001", UrlFormat = "https://aka.ms/aspire/diagnostics/{0}")]
+public enum RunSubMode
+{
+ ///
+ /// The AppHost is running normally. Resources are launched using their standard run behavior.
+ ///
+ Normal,
+
+ ///
+ /// The AppHost is running in watch sub-mode. Integrations that support watch
+ /// can launch their resources so that source changes are hot-reloaded.
+ ///
+ Watch,
+}
diff --git a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go
index 2feef942404..cd891e69fab 100644
--- a/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go
+++ b/tests/Aspire.Hosting.CodeGeneration.Go.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.go
@@ -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
@@ -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
@@ -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)} }
diff --git a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java
index 369281528bc..af3f0efff26 100644
--- a/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java
+++ b/tests/Aspire.Hosting.CodeGeneration.Java.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.java
@@ -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 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 reqArgs = new HashMap<>();
@@ -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
@@ -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
diff --git a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py
index c56ddd029af..a1a79e65d9c 100644
--- a/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py
+++ b/tests/Aspire.Hosting.CodeGeneration.Python.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.py
@@ -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"]
@@ -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."""
diff --git a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs
index ee8ed076aa6..25175b774da 100644
--- a/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs
+++ b/tests/Aspire.Hosting.CodeGeneration.Rust.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.rs
@@ -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 {
@@ -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> {
+ let mut args: HashMap = 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> {
let mut args: HashMap = HashMap::new();
diff --git a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts
index 727460ff55b..1aed016cc3e 100644
--- a/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts
+++ b/tests/Aspire.Hosting.CodeGeneration.TypeScript.Tests/Snapshots/TwoPassScanningGeneratedAspire.verified.ts
@@ -719,6 +719,21 @@ export enum ResourceCommandVisibility {
Api = "Api",
}
+/**
+ * Describes the run sub-mode the AppHost is running under (when `Operation` is `Run`).
+ *
+ * The run sub-mode is populated from configuration by the AppHost builder and surfaced through
+ * `RunSubMode`.
+ * It lets integrations vary how their resources are launched without changing the core hosting behavior.
+ * In `Publish` mode the sub-mode is always `Normal`.
+ */
+export enum RunSubMode {
+ /** The AppHost is running normally. Resources are launched using their standard run behavior. */
+ Normal = "Normal",
+ /** The AppHost is running in watch sub-mode. Integrations that support watch can launch their resources so that source changes are hot-reloaded. */
+ Watch = "Watch",
+}
+
/** Test persistence mode enum. */
export enum TestPersistenceMode {
None = "None",
@@ -3644,6 +3659,8 @@ export interface DistributedApplicationExecutionContext {
};
/** The operation currently being performed by the AppHost. */
operation(): Promise;
+ /** The run sub-mode the AppHost is running under. Only meaningful when `Operation` is `Run`; otherwise `Normal`. */
+ runSubMode(): Promise;
/** The `IServiceProvider` for the AppHost. */
serviceProvider(): ServiceProviderPromise;
/** The `IServiceProvider` for the AppHost. */
@@ -3657,6 +3674,8 @@ export interface DistributedApplicationExecutionContext {
export interface DistributedApplicationExecutionContextPromise extends PromiseLike {
/** The operation currently being performed by the AppHost. */
operation(): Promise;
+ /** The run sub-mode the AppHost is running under. Only meaningful when `Operation` is `Run`; otherwise `Normal`. */
+ runSubMode(): Promise;
/** The `IServiceProvider` for the AppHost. */
serviceProvider(): ServiceProviderPromise;
/** The `IServiceProvider` for the AppHost. */
@@ -3700,6 +3719,13 @@ class DistributedApplicationExecutionContextImpl implements DistributedApplicati
);
}
+ async runSubMode(): Promise {
+ return await this._client.invokeCapability(
+ 'Aspire.Hosting/DistributedApplicationExecutionContext.runSubMode',
+ { context: this._handle }
+ );
+ }
+
serviceProvider(): ServiceProviderPromise {
const promise = (async () => {
const handle = await this._client.invokeCapability(
@@ -3757,6 +3783,10 @@ class DistributedApplicationExecutionContextPromiseImpl implements DistributedAp
return this._promise.then(obj => obj.operation());
}
+ runSubMode(): Promise {
+ return this._promise.then(obj => obj.runSubMode());
+ }
+
serviceProvider(): ServiceProviderPromise {
return new ServiceProviderPromiseImpl(this._promise.then(obj => obj.serviceProvider()), this._client, false);
}
diff --git a/tests/Aspire.Hosting.Tests/OperationModesTests.cs b/tests/Aspire.Hosting.Tests/OperationModesTests.cs
index 09e2bdb5e50..8946f9e9a76 100644
--- a/tests/Aspire.Hosting.Tests/OperationModesTests.cs
+++ b/tests/Aspire.Hosting.Tests/OperationModesTests.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable ASPIREPIPELINES001
+#pragma warning disable ASPIREWATCH001
using Aspire.Hosting.Utils;
using Microsoft.AspNetCore.InternalTesting;
@@ -139,4 +140,113 @@ public void VerifyExplicitPublishModeInvocation()
.WithTestAndResourceLogging(outputHelper);
Assert.Equal(DistributedApplicationOperation.Publish, builder.ExecutionContext.Operation);
}
+
+ [Fact]
+ public void RunSubModeDefaultsToNormalInRunMode()
+ {
+ // Without any run sub-mode configuration the AppHost runs in the Normal sub-mode.
+
+ using var builder = TestDistributedApplicationBuilder
+ .Create()
+ .WithTestAndResourceLogging(outputHelper);
+
+ Assert.True(builder.ExecutionContext.IsRunMode);
+ Assert.Equal(RunSubMode.Normal, builder.ExecutionContext.RunSubMode);
+ }
+
+ [Fact]
+ public void RunSubModeIsWatchWhenConfigured()
+ {
+ // The "AppHost:RunSubMode" configuration key selects the run sub-mode, mirroring "AppHost:Operation".
+
+ using var builder = TestDistributedApplicationBuilder
+ .Create(["AppHost:RunSubMode=Watch"])
+ .WithTestAndResourceLogging(outputHelper);
+
+ Assert.True(builder.ExecutionContext.IsRunMode);
+ Assert.Equal(RunSubMode.Watch, builder.ExecutionContext.RunSubMode);
+ }
+
+ [Fact]
+ public void RunSubModeParsingIsCaseInsensitive()
+ {
+ // The value is parsed case-insensitively so callers do not have to match the enum casing exactly.
+
+ using var builder = TestDistributedApplicationBuilder
+ .Create(["AppHost:RunSubMode=watch"])
+ .WithTestAndResourceLogging(outputHelper);
+
+ Assert.Equal(RunSubMode.Watch, builder.ExecutionContext.RunSubMode);
+ }
+
+ [Fact]
+ public void RunSubModeFallsBackToNormalForUnknownValue()
+ {
+ // An unrecognized value must never fail the run; it falls back to Normal.
+
+ using var builder = TestDistributedApplicationBuilder
+ .Create(["AppHost:RunSubMode=bogus"])
+ .WithTestAndResourceLogging(outputHelper);
+
+ Assert.True(builder.ExecutionContext.IsRunMode);
+ Assert.Equal(RunSubMode.Normal, builder.ExecutionContext.RunSubMode);
+ }
+
+ [Fact]
+ public void RunSubModeIsNormalInPublishModeEvenWhenConfigured()
+ {
+ // The run sub-mode is only meaningful in run mode; publish mode always reports Normal.
+
+ using var builder = TestDistributedApplicationBuilder
+ .Create(["--operation", "publish", "--publisher", "manifest", "--output-path", "test-output-path", "AppHost:RunSubMode=Watch"])
+ .WithTestAndResourceLogging(outputHelper);
+
+ Assert.True(builder.ExecutionContext.IsPublishMode);
+ Assert.Equal(RunSubMode.Normal, builder.ExecutionContext.RunSubMode);
+ }
+
+ [Fact]
+ public void RunSubModeFallsBackToNormalForNumericValue()
+ {
+ // A numeric string is not a declared sub-mode name and must fall back to Normal. This guards against
+ // Enum.TryParse's behavior of accepting any numeric value (for example "42" => (RunSubMode)42).
+
+ using var builder = TestDistributedApplicationBuilder
+ .Create(["AppHost:RunSubMode=42"])
+ .WithTestAndResourceLogging(outputHelper);
+
+ Assert.True(builder.ExecutionContext.IsRunMode);
+ Assert.Equal(RunSubMode.Normal, builder.ExecutionContext.RunSubMode);
+ }
+
+ [Fact]
+ public void RunSubModeFallsBackToNormalForCompoundValue()
+ {
+ // A comma-separated value is not a declared sub-mode name and must fall back to Normal. This guards
+ // against Enum.TryParse accepting "Normal,Watch" as Watch even though RunSubMode is not [Flags].
+
+ using var builder = TestDistributedApplicationBuilder
+ .Create(["AppHost:RunSubMode=Normal,Watch"])
+ .WithTestAndResourceLogging(outputHelper);
+
+ Assert.True(builder.ExecutionContext.IsRunMode);
+ Assert.Equal(RunSubMode.Normal, builder.ExecutionContext.RunSubMode);
+ }
+
+ [Fact]
+ public void RunSubModeIsNormalWhenExecutionContextConstructedForPublish()
+ {
+ // The run sub-mode only applies to run mode. Even if a caller constructs options with a Watch
+ // sub-mode and a Publish operation, the execution context must report Normal (publish never watches).
+
+ var options = new DistributedApplicationExecutionContextOptions(DistributedApplicationOperation.Publish)
+ {
+ RunSubMode = RunSubMode.Watch
+ };
+
+ var context = new DistributedApplicationExecutionContext(options);
+
+ Assert.True(context.IsPublishMode);
+ Assert.Equal(RunSubMode.Normal, context.RunSubMode);
+ }
}