diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1b0335c..6696dbf 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -8,9 +8,9 @@
-
-
-
+
+
+
@@ -21,6 +21,7 @@
+
diff --git a/TemporalioSamples.sln b/TemporalioSamples.sln
index 4f16013..c8c62db 100644
--- a/TemporalioSamples.sln
+++ b/TemporalioSamples.sln
@@ -107,6 +107,17 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.NexusCanc
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "NexusCancellation", "NexusCancellation", "{7123C63D-3158-4C9A-8EAD-6D4F1295BC04}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.SampleWorkflow", "src\AspireIntegrations\TemporalioSamples.SampleWorkflow\TemporalioSamples.SampleWorkflow.csproj", "{6BA6C609-6D33-425B-883F-88ECE2E3DDB9}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AspireIntegrations", "AspireIntegrations", "{8781BE47-D710-408E-B143-4D5E20C356E2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.SampleWorker", "src\AspireIntegrations\TemporalioSamples.SampleWorker\TemporalioSamples.SampleWorker.csproj", "{FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.SampleClient", "src\AspireIntegrations\TemporalioSamples.SampleClient\TemporalioSamples.SampleClient.csproj", "{035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.SampleAppHost", "src\AspireIntegrations\TemporalioSamples.SampleAppHost\TemporalioSamples.SampleAppHost.csproj", "{CA136E75-FC34-44E1-B8B2-6E33D8AF520E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Temporal.Extensions.Aspire.Hosting", "src\AspireIntegrations\Temporal.Extensions.Aspire.Hosting\Temporal.Extensions.Aspire.Hosting.csproj", "{89D196AD-A6CE-42FB-BF46-C80BF579FE20}"
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "StandaloneActivity", "StandaloneActivity", "{EAB0C45A-7620-D2D2-2901-5E7FCBFFDA77}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.StandaloneActivity", "src\StandaloneActivity\TemporalioSamples.StandaloneActivity.csproj", "{240517A1-13B5-4A67-8519-BFCF2C4591B9}"
@@ -639,6 +650,66 @@ Global
{6D0BE4C4-9C4F-4A3D-78F1-B0B761568559}.Release|x64.Build.0 = Release|Any CPU
{6D0BE4C4-9C4F-4A3D-78F1-B0B761568559}.Release|x86.ActiveCfg = Release|Any CPU
{6D0BE4C4-9C4F-4A3D-78F1-B0B761568559}.Release|x86.Build.0 = Release|Any CPU
+ {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Debug|x64.Build.0 = Debug|Any CPU
+ {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Debug|x86.Build.0 = Debug|Any CPU
+ {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Release|x64.ActiveCfg = Release|Any CPU
+ {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Release|x64.Build.0 = Release|Any CPU
+ {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Release|x86.ActiveCfg = Release|Any CPU
+ {6BA6C609-6D33-425B-883F-88ECE2E3DDB9}.Release|x86.Build.0 = Release|Any CPU
+ {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Debug|x64.Build.0 = Debug|Any CPU
+ {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Debug|x86.Build.0 = Debug|Any CPU
+ {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Release|x64.ActiveCfg = Release|Any CPU
+ {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Release|x64.Build.0 = Release|Any CPU
+ {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Release|x86.ActiveCfg = Release|Any CPU
+ {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398}.Release|x86.Build.0 = Release|Any CPU
+ {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Debug|x64.Build.0 = Debug|Any CPU
+ {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Debug|x86.Build.0 = Debug|Any CPU
+ {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Release|x64.ActiveCfg = Release|Any CPU
+ {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Release|x64.Build.0 = Release|Any CPU
+ {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Release|x86.ActiveCfg = Release|Any CPU
+ {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8}.Release|x86.Build.0 = Release|Any CPU
+ {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Debug|x64.Build.0 = Debug|Any CPU
+ {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Debug|x86.Build.0 = Debug|Any CPU
+ {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Release|x64.ActiveCfg = Release|Any CPU
+ {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Release|x64.Build.0 = Release|Any CPU
+ {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Release|x86.ActiveCfg = Release|Any CPU
+ {CA136E75-FC34-44E1-B8B2-6E33D8AF520E}.Release|x86.Build.0 = Release|Any CPU
+ {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Debug|x64.Build.0 = Debug|Any CPU
+ {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Debug|x86.Build.0 = Debug|Any CPU
+ {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Release|Any CPU.Build.0 = Release|Any CPU
+ {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Release|x64.ActiveCfg = Release|Any CPU
+ {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Release|x64.Build.0 = Release|Any CPU
+ {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Release|x86.ActiveCfg = Release|Any CPU
+ {89D196AD-A6CE-42FB-BF46-C80BF579FE20}.Release|x86.Build.0 = Release|Any CPU
{240517A1-13B5-4A67-8519-BFCF2C4591B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{240517A1-13B5-4A67-8519-BFCF2C4591B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{240517A1-13B5-4A67-8519-BFCF2C4591B9}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -716,6 +787,12 @@ Global
{AF077751-E4B9-4696-93CB-74653F0BB6C4} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC}
{6D0BE4C4-9C4F-4A3D-78F1-B0B761568559} = {7123C63D-3158-4C9A-8EAD-6D4F1295BC04}
{7123C63D-3158-4C9A-8EAD-6D4F1295BC04} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC}
+ {8781BE47-D710-408E-B143-4D5E20C356E2} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC}
+ {6BA6C609-6D33-425B-883F-88ECE2E3DDB9} = {8781BE47-D710-408E-B143-4D5E20C356E2}
+ {FF13AD0E-4F24-4044-B8AD-5A57EF3AE398} = {8781BE47-D710-408E-B143-4D5E20C356E2}
+ {035FF43C-D9C8-4CCE-A35A-E4ABF6F842C8} = {8781BE47-D710-408E-B143-4D5E20C356E2}
+ {CA136E75-FC34-44E1-B8B2-6E33D8AF520E} = {8781BE47-D710-408E-B143-4D5E20C356E2}
+ {89D196AD-A6CE-42FB-BF46-C80BF579FE20} = {8781BE47-D710-408E-B143-4D5E20C356E2}
{EAB0C45A-7620-D2D2-2901-5E7FCBFFDA77} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC}
{240517A1-13B5-4A67-8519-BFCF2C4591B9} = {EAB0C45A-7620-D2D2-2901-5E7FCBFFDA77}
{5D493692-53AB-4FAA-BA4D-33B1E54E9A48} = {1A647B41-53D0-4638-AE5A-6630BAAE45FC}
diff --git a/src/AspireIntegrations/README.md b/src/AspireIntegrations/README.md
new file mode 100644
index 0000000..4e73c91
--- /dev/null
+++ b/src/AspireIntegrations/README.md
@@ -0,0 +1,277 @@
+# Temporal Extensions for .NET Aspire
+
+## Overview
+
+This project provides custom Aspire resource definitions that enable developers to integrate Temporal workflow servers into their Aspire applications with minimal configuration. It supports three deployment models:
+
+- **Local Testing** - Temporal server using `Temporalio.Testing.WorkflowEnvironment` for fast local development and testing
+- **Container-based** - Docker container running the official Temporal server image for development and staging environments
+- **CLI-based** - Temporal CLI server for environments where Docker isn't available
+
+### Key Features
+
+- ✅ **Service Discovery** - Automatic environment variable injection for dependent services
+- ✅ **Health Checks** - Built-in health checks integrated into Aspire's health pipeline
+- ✅ **Resource Management** - Start/Stop commands in the Aspire dashboard with proper state management
+
+## Prerequisites
+
+### Required
+- **[.NET SDK](https://dot.net)** 8.0 or later
+- **[Aspire tooling](https://learn.microsoft.com/dotnet/aspire/fundamentals/setup-tooling)** 13.0 or later
+
+### For Container-based Setup
+- **Docker** - Required to run the Temporal container
+- **Docker image** - `temporalio/temporal:latest` (automatically pulled)
+
+### For CLI-based Setup
+- **Temporal CLI** - Install via [Temporal CLI documentation](https://docs.temporal.io/cli/install)
+
+## Running the Project
+
+Using the Aspire CLI
+
+1. **Navigate to the AppHost project directory:**
+ ```bash
+ cd src/AspireIntegrations/
+ ```
+
+2. **Run the project using the Aspire CLI:**
+ ```bash
+ aspire run
+ ```
+
+> You can also run the project directly with `dotnet run` from the AppHost directory, or use your IDE's run configuration.
+
+## Setup Options
+
+### Local Server
+
+The local server setup uses a Temporal environment for fast testing without external dependencies. It will download and run the necessary Temporal server binaries.
+
+**AppHost.cs:**
+```csharp
+using Temporal.Extensions.Aspire.Hosting;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+// Add Temporal local server
+var temporal = builder.AddTemporalLocalDevServer();
+
+// Add a worker project that depends on Temporal
+builder.AddProject("worker")
+ .WaitFor(temporal)
+ .WithReference(temporal);
+
+builder.Build().Run();
+```
+
+**Advanced Configuration:**
+```csharp
+var temporal = builder.AddTemporalLocalDevServer(configure: options =>
+{
+ // Port configuration
+ options.UIPort = 8233; // Web UI port
+ options.MetricsPort = 9233; // Metrics endpoint port
+
+ // Network binding
+ options.TargetHost = "0.0.0.0:7233";
+
+ // Namespace configuration
+ options.Namespace = "default";
+ options.AdditionalNamespaces = ["orders", "analytics"];
+
+ // Use existing Temporal server binary
+ options.DevServerOptions.ExistingPath = "/usr/local/bin/temporal";
+
+ // UI configuration
+ options.UI = true;
+
+ // Search attributes for custom workflows
+ options.SearchAttributes = new[]
+ {
+ new SearchAttribute { Name = "Environment", ValueType = "Text" },
+ new SearchAttribute { Name = "UserId", ValueType = "Text" },
+ new SearchAttribute { Name = "ProcessingTime", ValueType = "Int" }
+ };
+
+ // Dynamic configuration values
+ options.DynamicConfigValues = [
+ "persistence.cassandra.hosts = cassandra-host:9042"
+ ];
+});
+```
+
+> Use `ExistingPath` to leverage a pre-installed Temporal binary
+
+---
+
+### Container-based
+
+Deploy Temporal using the CLI Docker container.
+
+**AppHost.cs:**
+```csharp
+using Temporal.Extensions.Aspire.Hosting;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+// Add Temporal as a Docker container
+var temporal = builder.AddTemporalDevContainer(
+ configure: options =>
+ {
+ options.ImageTag = "latest"; // Use specific version if needed
+ options.UI = true; // Enable Web UI
+ options.Namespace = "default";
+ });
+
+// Add dependent projects that require Temporal
+builder.AddProject("worker")
+ .WaitFor(temporal)
+ .WithReference(temporal);
+
+builder.Build().Run();
+```
+
+**Advanced Configuration:**
+```csharp
+var temporal = builder.AddTemporalDevContainer(configure: options =>
+{
+ // Network configuration
+ options.TargetHost = "127.0.0.1:7233";
+
+ // Namespaces
+ options.AdditionalNamespaces = ["default", "custom-ns"];
+
+ // Logging
+ options.DevServerOptions.LogLevel = "info";
+ options.DevServerOptions.LogFormat = "json";
+
+ // Search attributes for custom workflows
+ options.SearchAttributes = new[]
+ {
+ new SearchAttribute { Name = "CustomField", ValueType = "Text" },
+ new SearchAttribute { Name = "CustomInt", ValueType = "Int" }
+ };
+});
+```
+---
+
+### CLI-based
+
+Use the Temporal CLI server for environments without Docker.
+
+**AppHost.cs:**
+```csharp
+using Temporal.Extensions.Aspire.Hosting;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+// Add Temporal CLI server
+var temporal = builder.AddTemporalCliServer(
+ configure: options =>
+ {
+ options.Namespace = "default";
+ options.UI = true;
+ });
+
+// Add dependent projects that require Temporal
+builder.AddProject("worker")
+ .WaitFor(temporal)
+ .WithReference(temporal);
+
+builder.Build().Run();
+```
+
+**Advanced Configuration:**
+```csharp
+var temporal = builder.AddTemporalCliServer(configure: options =>
+{
+ // Port configuration
+ options.UIPort = 8233; // Web UI port
+ options.MetricsPort = 9233; // Metrics endpoint port
+
+ // Network binding
+ options.TargetHost = "0.0.0.0:7233";
+
+ // Namespace configuration
+ options.Namespace = "default";
+ options.AdditionalNamespaces = ["orders", "analytics"];
+
+ // UI configuration
+ options.UI = true;
+
+ // Search attributes for custom workflows
+ options.SearchAttributes = new[]
+ {
+ new SearchAttribute { Name = "Environment", ValueType = "Text" },
+ new SearchAttribute { Name = "UserId", ValueType = "Text" },
+ new SearchAttribute { Name = "ProcessingTime", ValueType = "Int" }
+ };
+
+ // Dynamic configuration values
+ options.DynamicConfigValues = [
+ "persistence.cassandra.hosts = cassandra-host:9042"
+ ];
+});
+```
+
+**Requirements:**
+- Temporal CLI must be installed and available in PATH
+- Run `temporal --version` to verify installation
+---
+
+### Connection Strings
+
+Dependent projects receive the following environment variables automatically:
+
+| Variable | Description |
+|----------|-------------|
+| `TEMPORAL_ADDRESS` | The gRPC server address (e.g., `localhost:7233` or container name:port) |
+| `TEMPORAL_UI_ADDRESS` | The Web UI address (e.g., `http://localhost:8233`) |
+| `TEMPORAL_NAMESPACE` | The namespace to use (matches the resource's configured namespace) |
+| `TEMPORAL_CODEC_AUTH` | Optional codec authentication token (if configured) |
+| `TEMPORAL_CODEC_ENDPOINT` | Optional codec server endpoint (if configured) |
+
+**Example usage:**
+```csharp
+var temporalAddress = Environment.GetEnvironmentVariable("TEMPORAL_ADDRESS");
+var temporalUiAddress = Environment.GetEnvironmentVariable("TEMPORAL_UI_ADDRESS");
+var temporalNamespace = Environment.GetEnvironmentVariable("TEMPORAL_NAMESPACE") ?? "default";
+```
+---
+
+## Configuration Options
+
+### TemporalResourceOptions
+
+The base configuration class for all resource types:
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `Namespace` | string | "default" | Primary namespace for workflows |
+| `AdditionalNamespaces` | List | ["default"] | Additional namespaces to register |
+| `Port` | int | 7233 | gRPC service port |
+| `UIPort` | int | 8233 | Web UI port |
+| `MetricsPort` | int | 9233 | Metrics endpoint port |
+| `UI` | bool | true | Enable Web UI |
+| `TargetHost` | string | "0.0.0.0:7233" | Bind address (IP:port format) |
+| `SearchAttributes` | List | null | Custom search attributes |
+| `DynamicConfigValues` | List | [] | Dynamic configuration |
+| `CodecEndpoint` | string | null | Codec server endpoint |
+| `CodecAuth` | string | null | Codec authentication token |
+
+### Container-specific Options
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `ImageTag` | string | "latest" | Docker image tag version |
+
+---
+
+## Resources
+
+- [Temporal Documentation](https://docs.temporal.io/)
+- [Temporal .NET SDK](https://github.com/temporalio/sdk-dotnet)
+- [.NET Aspire](https://learn.microsoft.com/dotnet/aspire)
+- [Aspire Integrations](https://learn.microsoft.com/dotnet/aspire/integrations)
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/Properties/AssemblyInfo.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..11d991e
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("TemporalioSamples.Tests")]
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/Temporal.Extensions.Aspire.Hosting.csproj b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/Temporal.Extensions.Aspire.Hosting.csproj
new file mode 100644
index 0000000..6f5e6dd
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/Temporal.Extensions.Aspire.Hosting.csproj
@@ -0,0 +1,13 @@
+
+
+
+ enable
+ enable
+ $(NoWarn);SA1010;SA1116;SA1413;SA1117;CA1031;SA1503;CS0109;CA1002;CA2227;CA1305
+
+
+
+
+
+
+
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalArgsBuilder.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalArgsBuilder.cs
new file mode 100644
index 0000000..ce7f157
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalArgsBuilder.cs
@@ -0,0 +1,62 @@
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Builds command-line arguments for Temporal dev server startup.
+///
+internal static class TemporalArgsBuilder
+{
+ ///
+ /// Builds the CLI arguments for a Temporal dev server.
+ /// When is true the IP is always "0.0.0.0" and the port
+ /// is always (container mode).
+ /// When false, IP and port are taken from and --ui-port is also emitted (CLI mode).
+ ///
+ /// The resource options containing host, ports, and namespace configuration.
+ /// If true, uses fixed container defaults; if false, uses options values and emits --ui-port.
+ /// An array of CLI arguments for the temporal server start-dev command.
+ internal static string[] BuildArgs(TemporalResourceOptions options, bool fixedIpAndPort = false)
+ {
+ var args = new List { "server", "start-dev" };
+
+ if (fixedIpAndPort)
+ {
+ args.AddRange(["--ip", "0.0.0.0"]);
+ args.AddRange(["--port", $"{TemporalResourceConstants.DefaultServiceEndpointPort}"]);
+ }
+ else
+ {
+ args.AddRange(["--ip", options.Ip]);
+ args.AddRange(["--port", options.Port.ToString()]);
+ args.AddRange(["--ui-port", options.UIPort.ToString()]);
+ }
+
+ if (options.IsHeadless)
+ args.Add("--headless");
+
+ args.AddRange(["--log-level", options.DevServerOptions.LogLevel]);
+ args.AddRange(["--log-format", options.DevServerOptions.LogFormat]);
+
+ foreach (var ns in options.AdditionalNamespaces)
+ args.AddRange(["--namespace", ns]);
+
+ if (options.SearchAttributes != null)
+ {
+ foreach (var sa in options.SearchAttributes)
+ args.AddRange(["--search-attribute", $"{sa.Name}={sa.ValueType}"]);
+ }
+
+ foreach (var dv in options.DynamicConfigValues)
+ args.AddRange(["--dynamic-config-value", dv]);
+
+ if (!string.IsNullOrEmpty(options.CodecAuth))
+ args.AddRange(["--codec-auth", options.CodecAuth]);
+
+ if (!string.IsNullOrEmpty(options.CodecEndpoint))
+ args.AddRange(["--codec-endpoint", options.CodecEndpoint]);
+
+ if (!string.IsNullOrEmpty(options.ApiKey))
+ args.AddRange(["--api-key", options.ApiKey]);
+
+ return args.ToArray();
+ }
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliLocator.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliLocator.cs
new file mode 100644
index 0000000..69f9610
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliLocator.cs
@@ -0,0 +1,42 @@
+using System.Runtime.InteropServices;
+
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Utility for locating and validating the Temporal CLI executable on the system PATH.
+///
+internal static class TemporalCliLocator
+{
+ ///
+ /// Throws when the temporal CLI executable
+ /// cannot be found on the system PATH.
+ ///
+ ///
+ /// Optional override for the availability check. When null (default) the real PATH is
+ /// inspected. Pass a custom delegate in tests to simulate presence or absence of the CLI
+ /// without depending on the test machine's PATH.
+ ///
+ internal static void EnsureAvailable(Func? isAvailable = null)
+ {
+ if (!(isAvailable ?? IsOnPath)())
+ {
+ throw new InvalidOperationException(
+ "The 'temporal' CLI executable was not found on PATH. " +
+ "Install it from https://docs.temporal.io/cli and ensure " +
+ "'temporal' is accessible on your PATH before using AddTemporalCliServer.");
+ }
+ }
+
+ private static bool IsOnPath()
+ {
+ var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
+ var executableName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? "temporal.exe"
+ : "temporal";
+
+ return pathEnv
+ .Split(Path.PathSeparator)
+ .Select(dir => Path.Combine(dir, executableName))
+ .Any(File.Exists);
+ }
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResource.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResource.cs
new file mode 100644
index 0000000..ee89906
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResource.cs
@@ -0,0 +1,23 @@
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Represents a Temporal server running via the CLI executable.
+///
+public class TemporalCliServerResource(string name, string workingDirectory = "./")
+ : ExecutableResource(name, "temporal", workingDirectory), IResourceWithConnectionString,
+ IResourceWithServiceDiscovery
+{
+ private EndpointReference? primaryEndpoint;
+
+ /// Gets the primary gRPC service endpoint.
+ public EndpointReference PrimaryEndpoint =>
+ primaryEndpoint ??= new(this, TemporalResourceConstants.ServiceEndpointName);
+
+ /// Gets the connection string expression for dependent services.
+ public ReferenceExpression ConnectionStringExpression =>
+ ReferenceExpression.Create(
+ $"{PrimaryEndpoint.Property(EndpointProperty.HostAndPort)}");
+
+ /// Gets or sets the resource configuration options.
+ public TemporalResourceOptions Options { get; set; } = new();
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResourceExtensions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResourceExtensions.cs
new file mode 100644
index 0000000..1140a79
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalCliServerResourceExtensions.cs
@@ -0,0 +1,92 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Extension methods for registering Temporal CLI server resources in Aspire.
+///
+public static class TemporalCliServerResourceExtensions
+{
+ ///
+ /// Adds a Temporal CLI server resource to the distributed application.
+ ///
+ /// The distributed application builder.
+ /// The resource name. Default is "temporal-cli-server".
+ /// Optional action to configure the resource options.
+ /// A builder for the Temporal CLI server resource.
+ public static IResourceBuilder AddTemporalCliServer(
+ this IDistributedApplicationBuilder builder,
+ string name = "temporal-cli-server",
+ Action? configure = null)
+ {
+ return builder.AddTemporalCliServer(name, configure, isTemporalCliAvailable: null);
+ }
+
+ ///
+ /// Adds a reference from a dependent service to a Temporal CLI server resource,
+ /// automatically injecting connection environment variables.
+ ///
+ /// The type of the destination resource.
+ /// The resource builder for the dependent service.
+ /// The Temporal CLI server resource builder.
+ /// The updated resource builder.
+ public static IResourceBuilder WithReference(
+ this IResourceBuilder builder, IResourceBuilder source)
+ where TDestination : IResourceWithEnvironment
+ {
+ return builder
+ .WithReference((IResourceBuilder)source)
+ .WithEnvironment(ctx =>
+ {
+ TemporalEnvironmentHelper.AddEnvironmentVariables(
+ ctx,
+ source.Resource.Options,
+ source.Resource.ConnectionStringExpression,
+ source.GetEndpoint(TemporalResourceConstants.UIEndpointName));
+ });
+ }
+
+ // Internal overload that accepts an injectable CLI availability check.
+ // Used by tests to simulate an absent 'temporal' binary without modifying PATH.
+ internal static IResourceBuilder AddTemporalCliServer(
+ this IDistributedApplicationBuilder builder,
+ string name,
+ Action? configure,
+ Func? isTemporalCliAvailable)
+ {
+ TemporalCliLocator.EnsureAvailable(isTemporalCliAvailable);
+ var resource = new TemporalCliServerResource(name);
+ configure?.Invoke(resource.Options);
+
+ var clientAccessor = TemporalHealthCheckHelper.RegisterCachedClientAccessor(
+ builder, resource, resource.Options.Namespace);
+
+ var healthCheckKey = $"{name}_check";
+ builder.Services.AddHealthChecks()
+ .AddTemporalHealthCheck(clientAccessor, healthCheckKey);
+
+ return builder.AddResource(resource)
+ .WithArgs(TemporalArgsBuilder.BuildArgs(resource.Options))
+ .ExcludeFromManifest()
+ .WithEndpoint(
+ targetPort: resource.Options.Port,
+ port: resource.Options.Port,
+ isProxied: false,
+ name: TemporalResourceConstants.ServiceEndpointName)
+ .WithHttpEndpoint(
+ targetPort: resource.Options.UIPort,
+ port: resource.Options.UIPort,
+ isProxied: false,
+ name: TemporalResourceConstants.UIEndpointName)
+ .WithHttpEndpoint(
+ targetPort: resource.Options.MetricsPort,
+ port: resource.Options.MetricsPort,
+ isProxied: false,
+ name: TemporalResourceConstants.MetricsEndpointName)
+ .WithHealthCheck(healthCheckKey)
+ .WithUrlForEndpoint(TemporalResourceConstants.UIEndpointName, url =>
+ {
+ url.DisplayText = "Dashboard";
+ });
+ }
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerOptions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerOptions.cs
new file mode 100644
index 0000000..573ae62
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerOptions.cs
@@ -0,0 +1,10 @@
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Configuration options specific to Temporal Docker container deployments.
+///
+public class TemporalContainerOptions : TemporalResourceOptions
+{
+ /// Gets or sets the Docker image tag. Default is "latest".
+ public string? ImageTag { get; set; } = TemporalResourceConstants.DefaultTag;
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResource.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResource.cs
new file mode 100644
index 0000000..520faac
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResource.cs
@@ -0,0 +1,20 @@
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Represents a Temporal server running as a Docker container.
+///
+public class TemporalContainerResource(string name) : ContainerResource(name), IResourceWithConnectionString, IResourceWithServiceDiscovery
+{
+ private EndpointReference? primaryEndpoint;
+
+ /// Gets the primary gRPC service endpoint.
+ public EndpointReference PrimaryEndpoint => primaryEndpoint ??= new(this, TemporalResourceConstants.ServiceEndpointName);
+
+ /// Gets the connection string expression for dependent services.
+ public ReferenceExpression ConnectionStringExpression =>
+ ReferenceExpression.Create(
+ $"{PrimaryEndpoint.Property(EndpointProperty.HostAndPort)}");
+
+ /// Gets or sets the container configuration options.
+ public TemporalContainerOptions Options { get; set; } = new();
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResourceExtensions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResourceExtensions.cs
new file mode 100644
index 0000000..ba2b574
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalContainerResourceExtensions.cs
@@ -0,0 +1,83 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Extension methods for registering Temporal container resources in Aspire.
+///
+public static class TemporalContainerResourceExtensions
+{
+ ///
+ /// Adds a Temporal Docker container resource to the distributed application.
+ ///
+ /// The distributed application builder.
+ /// The resource name. Default is "temporal-container".
+ /// Optional action to configure the container options.
+ /// A builder for the Temporal container resource.
+ public static IResourceBuilder AddTemporalDevContainer(
+ this IDistributedApplicationBuilder builder,
+ string name = "temporal-container",
+ Action? configure = null)
+ {
+ var resource = new TemporalContainerResource(name);
+ configure?.Invoke(resource.Options);
+
+ var clientAccessor = TemporalHealthCheckHelper.RegisterCachedClientAccessor(
+ builder, resource, resource.Options.Namespace);
+
+ var healthCheckKey = $"{name}_check";
+ builder.Services.AddHealthChecks()
+ .AddTemporalHealthCheck(clientAccessor, healthCheckKey);
+
+ return builder.AddResource(resource)
+ .WithImage(TemporalResourceConstants.TemporalImage,
+ resource.Options.ImageTag ?? TemporalResourceConstants.DefaultTag)
+ .WithImageRegistry("docker.io")
+ .WithArgs(TemporalArgsBuilder.BuildArgs(resource.Options, fixedIpAndPort: true))
+ .ExcludeFromManifest()
+ .WithEndpoint(
+ targetPort: TemporalResourceConstants.DefaultServiceEndpointPort,
+ port: resource.Options.Port,
+ isProxied: false,
+ name: TemporalResourceConstants.ServiceEndpointName)
+ .WithHttpEndpoint(
+ targetPort: TemporalResourceConstants.DefaultUIEndpointPort,
+ port: resource.Options.UIPort,
+ isProxied: false,
+ name: TemporalResourceConstants.UIEndpointName)
+ .WithHttpEndpoint(
+ targetPort: TemporalResourceConstants.DefaultMetricsEndpointPort,
+ port: resource.Options.MetricsPort,
+ isProxied: false,
+ name: TemporalResourceConstants.MetricsEndpointName)
+ .WithHealthCheck(healthCheckKey)
+ .WithUrlForEndpoint(TemporalResourceConstants.UIEndpointName, url =>
+ {
+ url.DisplayText = "Dashboard";
+ });
+ }
+
+ ///
+ /// Adds a reference from a dependent service to a Temporal container resource,
+ /// automatically injecting connection environment variables.
+ ///
+ /// The type of the destination resource.
+ /// The resource builder for the dependent service.
+ /// The Temporal container resource builder.
+ /// The updated resource builder.
+ public static IResourceBuilder WithReference(
+ this IResourceBuilder builder, IResourceBuilder source)
+ where TDestination : IResourceWithEnvironment
+ {
+ return builder
+ .WithReference((IResourceBuilder)source)
+ .WithEnvironment(ctx =>
+ {
+ TemporalEnvironmentHelper.AddEnvironmentVariables(
+ ctx,
+ source.Resource.Options,
+ source.Resource.ConnectionStringExpression,
+ source.GetEndpoint(TemporalResourceConstants.UIEndpointName));
+ });
+ }
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalEnvironmentHelper.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalEnvironmentHelper.cs
new file mode 100644
index 0000000..67c6e66
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalEnvironmentHelper.cs
@@ -0,0 +1,33 @@
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Helper for injecting Temporal connection details as environment variables into dependent services.
+///
+internal static class TemporalEnvironmentHelper
+{
+ ///
+ /// Adds Temporal connection and configuration environment variables to a dependent service.
+ /// This helper only maps the resource connection details and namespace into dependent services.
+ /// It intentionally does not duplicate all resource-specific Temporal environment variables.
+ ///
+ /// The environment callback context for the dependent service.
+ /// The Temporal resource options containing namespace and codec configuration.
+ /// The gRPC server address expression.
+ /// The Web UI address expression.
+ internal static void AddEnvironmentVariables(
+ EnvironmentCallbackContext ctx,
+ TemporalResourceOptions options,
+ object temporalAddress,
+ object temporalUiAddress)
+ {
+ ctx.EnvironmentVariables["TEMPORAL_ADDRESS"] = temporalAddress;
+ ctx.EnvironmentVariables["TEMPORAL_UI_ADDRESS"] = temporalUiAddress;
+ ctx.EnvironmentVariables["TEMPORAL_NAMESPACE"] = options.Namespace;
+
+ if (!string.IsNullOrEmpty(options.CodecAuth))
+ ctx.EnvironmentVariables["TEMPORAL_CODEC_AUTH"] = options.CodecAuth;
+
+ if (!string.IsNullOrEmpty(options.CodecEndpoint))
+ ctx.EnvironmentVariables["TEMPORAL_CODEC_ENDPOINT"] = options.CodecEndpoint;
+ }
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheck.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheck.cs
new file mode 100644
index 0000000..0c517e1
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheck.cs
@@ -0,0 +1,36 @@
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Temporalio.Api.WorkflowService.V1;
+using Temporalio.Client;
+
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Health check implementation for Temporal servers.
+///
+public class TemporalHealthCheck(Func> clientAccessor) : IHealthCheck
+{
+ ///
+ /// Checks the health of the Temporal server by calling GetSystemInfoAsync.
+ ///
+ /// The health check context providing access to registered services.
+ /// The cancellation token for this operation.
+ /// A HealthCheckResult indicating whether the server is reachable and healthy.
+ public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
+ {
+ var client = await clientAccessor(cancellationToken);
+ if (client is null)
+ return HealthCheckResult.Unhealthy("Temporal client not yet initialized");
+
+ try
+ {
+ await client.WorkflowService.GetSystemInfoAsync(
+ new GetSystemInfoRequest(),
+ new RpcOptions { CancellationToken = cancellationToken });
+ return HealthCheckResult.Healthy();
+ }
+ catch (Exception e)
+ {
+ return HealthCheckResult.Unhealthy("Unable to reach Temporal server", e);
+ }
+ }
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckBuilderExtensions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckBuilderExtensions.cs
new file mode 100644
index 0000000..6a25ee4
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckBuilderExtensions.cs
@@ -0,0 +1,35 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Temporalio.Client;
+
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Extension methods for adding Temporal health checks to Aspire applications.
+///
+public static class TemporalHealthCheckBuilderExtensions
+{
+ ///
+ /// Adds a Temporal health check to the health checks builder.
+ ///
+ /// The health checks builder.
+ /// A function that provides access to a Temporal client.
+ /// The health check name. Default is "temporal".
+ /// Optional tags to associate with the health check.
+ /// Optional timeout for the health check.
+ /// The updated health checks builder.
+ public static IHealthChecksBuilder AddTemporalHealthCheck(
+ this IHealthChecksBuilder builder,
+ Func> clientAccessor,
+ string name = "temporal",
+ IEnumerable? tags = null,
+ TimeSpan? timeout = null)
+ {
+ return builder.Add(new HealthCheckRegistration(
+ name,
+ _ => new TemporalHealthCheck(clientAccessor),
+ HealthStatus.Unhealthy,
+ tags,
+ timeout));
+ }
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckHelper.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckHelper.cs
new file mode 100644
index 0000000..b6aea70
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalHealthCheckHelper.cs
@@ -0,0 +1,85 @@
+using Temporalio.Client;
+
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Helper for registering cached Temporal client accessors for health checks.
+///
+internal static class TemporalHealthCheckHelper
+{
+ ///
+ /// Subscribes to for a CLI or container resource,
+ /// creates a once the endpoint is available, and returns an accessor
+ /// that the health check can call on every probe.
+ /// The cached client is replaced on each subsequent event so restarts are covered.
+ ///
+ /// The distributed application builder for subscribing to events.
+ /// The Temporal resource being monitored for connection string availability.
+ /// The Temporal namespace for client connections.
+ /// A function that accepts a cancellation token and returns the cached ITemporalClient or null if not yet connected.
+ internal static Func> RegisterCachedClientAccessor(
+ IDistributedApplicationBuilder builder,
+ IResource resource,
+ string @namespace)
+ {
+ ITemporalClient? cachedClient = null;
+ string? hostPort = null;
+
+ async Task EnsureClientConnectedAsync(CancellationToken cancellationToken)
+ {
+ if (cachedClient is not null)
+ return cachedClient;
+
+ if (string.IsNullOrEmpty(hostPort))
+ return null;
+
+ try
+ {
+ cachedClient = await TemporalClient.ConnectAsync(new TemporalClientConnectOptions
+ {
+ Namespace = @namespace,
+ TargetHost = hostPort
+ });
+ return cachedClient;
+ }
+ catch (InvalidOperationException)
+ {
+ cachedClient = null;
+ return null;
+ }
+ }
+
+ builder.Eventing.Subscribe(resource, async (@event, _) =>
+ {
+ try
+ {
+ if (!@event.Resource.TryGetEndpoints(out var endpoints))
+ return;
+
+ var serviceEndpoint = endpoints.Single(e => e.Name == TemporalResourceConstants.ServiceEndpointName);
+ hostPort = $"{serviceEndpoint.TargetHost}:{serviceEndpoint.Port}";
+ cachedClient = null;
+
+ // The endpoint can be published before Temporal is accepting connections.
+ // Retry a few times but never throw from this callback.
+ const int maxAttempts = 30;
+ for (var attempt = 1; attempt <= maxAttempts; attempt++)
+ {
+ var client = await EnsureClientConnectedAsync(CancellationToken.None);
+ if (client is not null)
+ break;
+
+ if (attempt < maxAttempts)
+ await Task.Delay(TimeSpan.FromMilliseconds(500), CancellationToken.None);
+ }
+ }
+ catch
+ {
+ // Resource startup must not fail because client warm-up failed.
+ cachedClient = null;
+ }
+ });
+
+ return EnsureClientConnectedAsync;
+ }
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResource.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResource.cs
new file mode 100644
index 0000000..084351f
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResource.cs
@@ -0,0 +1,16 @@
+using Temporalio.Testing;
+
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Represents a Temporal server running locally via .
+///
+public class TemporalLocalResource(string name)
+ : Resource(name), IResourceWithServiceDiscovery
+{
+ /// Gets or sets the underlying workflow environment instance.
+ public WorkflowEnvironment? WorkflowEnvironment { get; set; }
+
+ /// Gets or sets the resource configuration options.
+ public TemporalResourceOptions Options { get; set; } = new();
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceExtensions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceExtensions.cs
new file mode 100644
index 0000000..b6f4bb5
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceExtensions.cs
@@ -0,0 +1,240 @@
+using Aspire.Hosting.Eventing;
+using Aspire.Hosting.Lifecycle;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Temporalio.Testing;
+
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Extension methods for registering local Temporal server resources in Aspire.
+///
+public static class TemporalLocalResourceExtensions
+{
+ ///
+ /// Adds a local Temporal server resource (via WorkflowEnvironment) to the distributed application.
+ ///
+ /// The distributed application builder.
+ /// The resource name. Default is "temporal-local".
+ /// Optional action to configure the resource options.
+ /// A builder for the local Temporal server resource.
+ public static IResourceBuilder AddTemporalLocalDevServer(
+ this IDistributedApplicationBuilder builder,
+ string name = "temporal-local",
+ Action? configure = null)
+ {
+ builder.Services.TryAddEventingSubscriber();
+
+ var resource = new TemporalLocalResource(name);
+
+ configure?.Invoke(resource.Options);
+
+ var healthCheckKey = $"{name}_check";
+ builder.Services.AddHealthChecks()
+ .AddTemporalHealthCheck(_ => Task.FromResult(resource.WorkflowEnvironment?.Client), healthCheckKey);
+
+ var resourceBuilder = builder.AddResource(resource)
+ .ExcludeFromManifest()
+ .WithEndpoint(
+ targetPort: resource.Options.Port,
+ port: resource.Options.Port,
+ isProxied: false,
+ name: TemporalResourceConstants.ServiceEndpointName)
+ .WithHttpEndpoint(
+ targetPort: resource.Options.UIPort,
+ port: resource.Options.UIPort,
+ isProxied: false,
+ name: TemporalResourceConstants.UIEndpointName)
+ .WithHttpEndpoint(
+ targetPort: resource.Options.MetricsPort,
+ port: resource.Options.MetricsPort,
+ isProxied: false,
+ name: TemporalResourceConstants.MetricsEndpointName)
+ .WithHealthCheck(healthCheckKey)
+ .WithUrlForEndpoint(TemporalResourceConstants.UIEndpointName, url => { url.DisplayText = "Dashboard"; })
+ .WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = "temporal-local",
+ CreationTimeStamp = DateTime.UtcNow,
+ State = KnownResourceStates.NotStarted,
+ Properties =
+ [
+ new(CustomResourceKnownProperties.Source, "Temporalio.Testing.WorkflowEnvironment")
+ ]
+ });
+
+ resourceBuilder.WithCommand(
+ name: KnownResourceCommands.StopCommand,
+ displayName: "Stop",
+ executeCommand: async context =>
+ {
+ var notifications = context.ServiceProvider
+ .GetRequiredService();
+ var resourceLogger = context.ServiceProvider
+ .GetRequiredService()
+ .GetLogger(resource);
+ var eventing = context.ServiceProvider
+ .GetRequiredService();
+
+ await notifications.PublishUpdateAsync(resource, s => s with
+ {
+ State = KnownResourceStates.Stopping
+ });
+
+ try
+ {
+ // Publish ResourceStoppedEvent to trigger subscriber cleanup and keep _environments dictionary in sync
+ // The subscriber's OnResourceStoppedAsync performs the actual ShutdownAsync exactly once.
+ var resourceEvent = new ResourceEvent(resource, resource.Name, new CustomResourceSnapshot
+ {
+ ResourceType = "temporal-local",
+ CreationTimeStamp = DateTime.UtcNow,
+ State = KnownResourceStates.Exited,
+ Properties = []
+ });
+ var stoppedEvent = new ResourceStoppedEvent(resource, context.ServiceProvider, resourceEvent);
+ await eventing.PublishAsync(stoppedEvent, context.CancellationToken);
+
+ await notifications.PublishUpdateAsync(resource, s => s with
+ {
+ State = KnownResourceStates.Exited
+ });
+
+ return CommandResults.Success();
+ }
+ catch (Exception ex)
+ {
+ resourceLogger.LogError(ex, "Error shutting down Temporal test server '{ResourceName}'", resource.Name);
+ return CommandResults.Failure(ex.Message);
+ }
+ },
+ commandOptions: new CommandOptions
+ {
+ IconName = "Stop",
+ IconVariant = IconVariant.Filled,
+ IsHighlighted = true,
+ UpdateState = context =>
+ {
+ var state = context.ResourceSnapshot.State?.Text;
+ if (IsStarting(state) || HasNoState(state))
+ {
+ return ResourceCommandState.Disabled;
+ }
+ else if (IsRunning(state))
+ {
+ return ResourceCommandState.Enabled;
+ }
+ else
+ {
+ return ResourceCommandState.Hidden;
+ }
+ }
+ });
+
+ resourceBuilder.WithCommand(
+ name: KnownResourceCommands.StartCommand,
+ displayName: "Start",
+ executeCommand: async context =>
+ {
+ var notifications = context.ServiceProvider
+ .GetRequiredService();
+ var resourceLogger = context.ServiceProvider
+ .GetRequiredService()
+ .GetLogger(resource);
+ var eventing = context.ServiceProvider
+ .GetRequiredService();
+
+ await notifications.PublishUpdateAsync(resource, s => s with
+ {
+ State = KnownResourceStates.Starting
+ });
+
+ try
+ {
+ resourceLogger.LogInformation("Starting Temporal test server for resource '{ResourceName}'...", resource.Name);
+ var env = await WorkflowEnvironment.StartLocalAsync(resource.Options);
+ resource.WorkflowEnvironment = env;
+
+ var targetHost = env.Client.Connection.Options.TargetHost ?? "unknown";
+
+ resourceLogger.LogInformation(
+ "Temporal test server started successfully. Target: {TargetHost}, Namespace: {Namespace}",
+ targetHost,
+ resource.Options.Namespace);
+
+ await notifications.PublishUpdateAsync(resource, s => s with
+ {
+ State = KnownResourceStates.Running,
+ Properties =
+ [
+ new ResourcePropertySnapshot(CustomResourceKnownProperties.Source,
+ "Temporalio.Testing.WorkflowEnvironment"),
+ new ResourcePropertySnapshot("temporal.target-host", targetHost),
+ new ResourcePropertySnapshot("temporal.namespace", resource.Options.Namespace)
+ ]
+ });
+
+ // Publish ResourceReadyEvent to signal that the resource is ready
+ await eventing.PublishAsync(new ResourceReadyEvent(resource, context.ServiceProvider), context.CancellationToken);
+
+ return CommandResults.Success();
+ }
+ catch (Exception ex)
+ {
+ resourceLogger.LogError(ex, "Failed to start Temporal test server for resource '{ResourceName}'", resource.Name);
+ await notifications.PublishUpdateAsync(resource, s => s with
+ {
+ State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error)
+ });
+ return CommandResults.Failure(ex.Message);
+ }
+ },
+ commandOptions: new CommandOptions
+ {
+ IconName = "Play",
+ IconVariant = IconVariant.Filled,
+ IsHighlighted = true,
+ UpdateState = context =>
+ {
+ var state = context.ResourceSnapshot.State?.Text;
+ if (IsStarting(state) || IsRuntimeUnhealthy(state) || HasNoState(state))
+ {
+ return ResourceCommandState.Disabled;
+ }
+
+ if (IsStopped(state) || IsWaiting(state))
+ {
+ return ResourceCommandState.Enabled;
+ }
+
+ return ResourceCommandState.Hidden;
+ }
+ });
+
+ return resourceBuilder;
+
+ static bool IsStopped(string? state) => KnownResourceStates.TerminalStates.Contains(state) ||
+ state == KnownResourceStates.NotStarted || state == "Unknown";
+
+ static bool IsRunning(string? state) => state == KnownResourceStates.Running;
+ static bool IsStarting(string? state) => state == KnownResourceStates.Starting;
+ static bool IsWaiting(string? state) => state == KnownResourceStates.Waiting;
+ static bool IsRuntimeUnhealthy(string? state) => state == KnownResourceStates.RuntimeUnhealthy;
+ static bool HasNoState(string? state) => string.IsNullOrEmpty(state);
+ }
+
+ public static IResourceBuilder WithReference(
+ this IResourceBuilder builder, IResourceBuilder source)
+ where TDestination : IResourceWithEnvironment
+ {
+ return builder
+ .WithEnvironment(ctx =>
+ {
+ TemporalEnvironmentHelper.AddEnvironmentVariables(
+ ctx,
+ source.Resource.Options,
+ $"localhost:{source.Resource.Options.Port}",
+ $"http://localhost:{source.Resource.Options.UIPort}");
+ });
+ }
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceSubscriber.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceSubscriber.cs
new file mode 100644
index 0000000..251a340
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalLocalResourceSubscriber.cs
@@ -0,0 +1,168 @@
+using Aspire.Hosting.Eventing;
+using Aspire.Hosting.Lifecycle;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Temporalio.Testing;
+
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Event subscriber for managing local Temporal server lifecycle.
+///
+public class TemporalLocalResourceSubscriber : IDistributedApplicationEventingSubscriber
+{
+ private readonly Dictionary environments = [];
+
+ ///
+ /// Subscribes to resource events for initializing and managing local Temporal servers.
+ ///
+ /// The distributed application eventing service.
+ /// The execution context containing application state and mode information.
+ /// The cancellation token for this operation.
+ /// A completed task once event subscriptions are registered.
+ public Task SubscribeAsync(IDistributedApplicationEventing eventing,
+ DistributedApplicationExecutionContext executionContext,
+ CancellationToken cancellationToken)
+ {
+ // Don't start server in publish mode
+ if (executionContext.IsPublishMode)
+ {
+ return Task.CompletedTask;
+ }
+
+ // Subscribe to InitializeResourceEvent for each TemporalLocalResource
+ eventing.Subscribe(OnInitializeAsync);
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Handles initialization of local Temporal resources.
+ ///
+ private async Task OnInitializeAsync(InitializeResourceEvent @event, CancellationToken cancellationToken = default)
+ {
+ if (@event.Resource is not TemporalLocalResource resource)
+ {
+ return;
+ }
+
+ var resourceLoggerService = @event.Services.GetRequiredService();
+
+ // Subscribe to ResourceStoppedEvent for this specific resource to handle cleanup
+ @event.Eventing.Subscribe(resource,
+ (stopEvent, _) => OnResourceStoppedAsync(stopEvent, resourceLoggerService, resource));
+
+ await StartTemporalTestServerAsync(resource, @event.Eventing,
+ @event.Notifications, resourceLoggerService, @event.Services, cancellationToken);
+ }
+
+ ///
+ /// Starts the local Temporal test server and publishes connection events.
+ ///
+ private async Task StartTemporalTestServerAsync(TemporalLocalResource resource,
+ IDistributedApplicationEventing eventing,
+ ResourceNotificationService resourceNotificationService,
+ ResourceLoggerService resourceLoggerService,
+ IServiceProvider serviceProvider,
+ CancellationToken cancellationToken)
+ {
+ var resourceLogger = resourceLoggerService.GetLogger(resource);
+
+ try
+ {
+ // Publish starting state
+ await resourceNotificationService.PublishUpdateAsync(resource, state => state with
+ {
+ State = new ResourceStateSnapshot(KnownResourceStates.Starting, KnownResourceStateStyles.Info),
+ StartTimeStamp = DateTime.UtcNow
+ });
+
+ resourceLogger.LogInformation("Starting Temporal test server for resource '{ResourceName}'...",
+ resource.Name);
+
+ var env = await WorkflowEnvironment.StartLocalAsync(resource.Options);
+
+ // Store the environment for later shutdown (before publishing events)
+ environments[resource.Name] = env;
+
+ // Set the environment on the resource so it can be accessed
+ resource.WorkflowEnvironment = env;
+
+ var targetHost = env.Client.Connection.Options.TargetHost ?? "unknown";
+ var namespaces = string.Join(", ", resource.Options.AdditionalNamespaces);
+
+ resourceLogger.LogInformation(
+ "Temporal test server started successfully. Target: {TargetHost}, Namespaces: {Namespaces}",
+ targetHost,
+ namespaces);
+
+ // Publish running state with properties
+ await resourceNotificationService.PublishUpdateAsync(resource, state => state with
+ {
+ State = KnownResourceStates.Running,
+ Properties =
+ [
+ new ResourcePropertySnapshot(CustomResourceKnownProperties.Source,
+ "Temporalio.Testing.WorkflowEnvironment"),
+ new ResourcePropertySnapshot("temporal.target-host", targetHost),
+ new ResourcePropertySnapshot("temporal.namespace", resource.Options.Namespace),
+ new ResourcePropertySnapshot("temporal.namespaces", namespaces)
+ ]
+ });
+
+ // Now publish events (after environment is properly stored and assigned)
+ var connectionStringAvailableEvent = new ConnectionStringAvailableEvent(resource, serviceProvider);
+ await eventing.PublishAsync(connectionStringAvailableEvent, cancellationToken).ConfigureAwait(false);
+
+ // Publish ResourceReadyEvent to signal that the resource is ready
+ await eventing.PublishAsync(new ResourceReadyEvent(resource, serviceProvider), cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ resourceLogger.LogError(ex, "Failed to start Temporal test server for resource '{ResourceName}'",
+ resource.Name);
+
+ // Publish failed state
+ await resourceNotificationService.PublishUpdateAsync(resource, state => state with
+ {
+ State = new ResourceStateSnapshot(KnownResourceStates.FailedToStart, KnownResourceStateStyles.Error)
+ });
+
+ throw;
+ }
+ }
+
+ ///
+ /// Handles cleanup when a local Temporal resource is stopped.
+ ///
+ private async Task OnResourceStoppedAsync(ResourceStoppedEvent @event,
+ ResourceLoggerService resourceLoggerService, TemporalLocalResource resource)
+ {
+ var resourceName = @event.Resource.Name;
+ var resourceLogger = resourceLoggerService.GetLogger(resource);
+
+ // Get environment from resource property first, fallback to tracking dictionary
+ var env = resource.WorkflowEnvironment ?? environments.GetValueOrDefault(resourceName);
+
+ if (env != null)
+ {
+ try
+ {
+ resourceLogger.LogInformation("Shutting down Temporal test server '{ResourceName}'...", resourceName);
+ await env.ShutdownAsync();
+ resource.WorkflowEnvironment = null;
+ resourceLogger.LogInformation("Temporal test server '{ResourceName}' shut down successfully.",
+ resourceName);
+ }
+ catch (Exception ex)
+ {
+ resourceLogger.LogError(ex, "Error shutting down Temporal test server '{ResourceName}'", resourceName);
+ throw;
+ }
+ finally
+ {
+ environments.Remove(resourceName);
+ }
+ }
+ }
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceConstants.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceConstants.cs
new file mode 100644
index 0000000..83dcb54
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceConstants.cs
@@ -0,0 +1,34 @@
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Constants used for Temporal resource configuration and endpoints.
+///
+public static class TemporalResourceConstants
+{
+ /// The name of the gRPC service endpoint.
+ public const string ServiceEndpointName = "grpc";
+
+ /// The default port for the gRPC service endpoint.
+ public const int DefaultServiceEndpointPort = 7233;
+
+ /// The name of the Web UI endpoint.
+ public const string UIEndpointName = "ui";
+
+ /// The default port for the Web UI endpoint.
+ public const int DefaultUIEndpointPort = 8233;
+
+ /// The name of the metrics endpoint.
+ public const string MetricsEndpointName = "metrics";
+
+ /// The default port for the metrics endpoint.
+ public const int DefaultMetricsEndpointPort = 9233;
+
+ /// The Docker image name for Temporal.
+ public const string TemporalImage = "temporalio/temporal";
+
+ /// The default Docker image tag.
+ public const string DefaultTag = "latest";
+
+ /// The default working directory for Temporal CLI execution.
+ public const string DefaultWorkingDirectory = "./";
+}
diff --git a/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceOptions.cs b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceOptions.cs
new file mode 100644
index 0000000..7f9ccd4
--- /dev/null
+++ b/src/AspireIntegrations/Temporal.Extensions.Aspire.Hosting/TemporalResourceOptions.cs
@@ -0,0 +1,97 @@
+using Temporalio.Testing;
+
+namespace Temporal.Extensions.Aspire.Hosting;
+
+///
+/// Configuration options for Temporal resources (local, container, and CLI-based).
+/// Extends with Aspire-specific settings.
+///
+public class TemporalResourceOptions : WorkflowEnvironmentStartLocalOptions
+{
+ private List extraNamespaces = [];
+
+ public TemporalResourceOptions()
+ {
+ // Set defaults that differ from base class
+ UIPort = TemporalResourceConstants.DefaultUIEndpointPort;
+ UI = true;
+ TargetHost = $"0.0.0.0:{TemporalResourceConstants.DefaultServiceEndpointPort}";
+ }
+
+ ///
+ /// Gets or sets additional namespaces beyond the primary namespace.
+ /// Always includes the primary in the returned list.
+ ///
+ public new List AdditionalNamespaces
+ {
+ get
+ {
+ var result = new List { Namespace };
+ foreach (var ns in extraNamespaces)
+ {
+ if (ns != Namespace && !result.Contains(ns))
+ result.Add(ns);
+ }
+ return result;
+ }
+
+ set
+ {
+ extraNamespaces = value == null
+ ? []
+ : value.Where(ns => !string.IsNullOrEmpty(ns) && ns != Namespace).Distinct().ToList();
+ }
+ }
+
+ ///
+ /// Gets the gRPC port, parsed from .
+ ///
+ public int Port
+ {
+ get
+ {
+ if (string.IsNullOrEmpty(TargetHost))
+ throw new InvalidOperationException("TargetHost must be set before accessing Port.");
+
+ var parts = TargetHost.Split(':');
+ if (parts.Length == 2 && int.TryParse(parts[1], out var port))
+ return port;
+
+ throw new InvalidOperationException($"TargetHost '{TargetHost}' is not in the expected 'ip:port' format.");
+ }
+ }
+
+ ///
+ /// Gets the IP address to bind to, parsed from TargetHost.
+ /// Maps to --ip CLI argument and DevServerOptions.Ip concept.
+ ///
+ public string Ip
+ {
+ get
+ {
+ if (string.IsNullOrEmpty(TargetHost))
+ throw new InvalidOperationException("TargetHost must be set before accessing Ip.");
+
+ var parts = TargetHost.Split(':');
+ if (parts.Length > 0 && !string.IsNullOrEmpty(parts[0]))
+ return parts[0];
+
+ return "0.0.0.0";
+ }
+ }
+
+ /// Gets or sets the metrics endpoint port. Default is 9233.
+ public int MetricsPort { get; set; } = TemporalResourceConstants.DefaultMetricsEndpointPort;
+
+ /// Gets a value indicating whether the UI is disabled.
+ public bool IsHeadless => !UI;
+
+ /// Gets or sets dynamic configuration values for the Temporal server.
+ public List DynamicConfigValues { get; set; } = [];
+
+ /// Gets or sets the codec authentication token for encrypted payloads.
+ public string? CodecAuth { get; set; }
+
+ /// Gets or sets the codec server endpoint for encrypted payloads.
+ public string? CodecEndpoint { get; set; }
+}
diff --git a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs
new file mode 100644
index 0000000..75c4d7d
--- /dev/null
+++ b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/AppHost.cs
@@ -0,0 +1,15 @@
+using Temporal.Extensions.Aspire.Hosting;
+
+var builder = DistributedApplication.CreateBuilder(args);
+
+var temporal = builder.AddTemporalCliServer();
+
+builder.AddProject("sample-temporal-worker")
+ .WaitFor(temporal)
+ .WithReference(temporal);
+
+builder.AddProject("sample-temporal-client")
+ .WaitFor(temporal)
+ .WithReference(temporal);
+
+builder.Build().Run();
diff --git a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/Properties/launchSettings.json b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/Properties/launchSettings.json
new file mode 100644
index 0000000..84c4b13
--- /dev/null
+++ b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/Properties/launchSettings.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:17197;http://localhost:15132",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21202",
+ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23199",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22048"
+ }
+ },
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:15132",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "DOTNET_ENVIRONMENT": "Development",
+ "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19084",
+ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18042",
+ "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20034"
+ }
+ }
+ }
+}
diff --git a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/TemporalioSamples.SampleAppHost.csproj b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/TemporalioSamples.SampleAppHost.csproj
new file mode 100644
index 0000000..621576e
--- /dev/null
+++ b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/TemporalioSamples.SampleAppHost.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ enable
+ enable
+ 4a9378ba-a2ca-4863-9c5c-98903998bef0
+
+
+
+
+
+
+
+
+
diff --git a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.Development.json b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.json b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.json
new file mode 100644
index 0000000..31c092a
--- /dev/null
+++ b/src/AspireIntegrations/TemporalioSamples.SampleAppHost/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Aspire.Hosting.Dcp": "Warning"
+ }
+ }
+}
diff --git a/src/AspireIntegrations/TemporalioSamples.SampleClient/Program.cs b/src/AspireIntegrations/TemporalioSamples.SampleClient/Program.cs
new file mode 100644
index 0000000..219b12d
--- /dev/null
+++ b/src/AspireIntegrations/TemporalioSamples.SampleClient/Program.cs
@@ -0,0 +1,24 @@
+using Temporalio.Client;
+using Temporalio.Common.EnvConfig;
+using TemporalioSamples.SampleWorkflow;
+
+try
+{
+ var connectOptions = ClientEnvConfig.LoadClientConnectOptions();
+ Console.WriteLine("\nAttempting to connect client to temporal server...");
+
+ var client = await TemporalClient.ConnectAsync(connectOptions);
+ Console.WriteLine("✅ Client connected successfully!");
+
+ await client.StartWorkflowAsync(
+ (SimpleWorkflow wf) => wf.RunAsync(),
+ new(id: "simple-workflow-id", taskQueue: "simple-task-queue"));
+
+ Console.WriteLine("✅ Workflow invoked successfully!");
+}
+#pragma warning disable CA1031
+catch (Exception ex)
+#pragma warning restore CA1031
+{
+ Console.WriteLine($"❌ Failed to start workflow: {ex.Message}");
+}
diff --git a/src/AspireIntegrations/TemporalioSamples.SampleClient/TemporalioSamples.SampleClient.csproj b/src/AspireIntegrations/TemporalioSamples.SampleClient/TemporalioSamples.SampleClient.csproj
new file mode 100644
index 0000000..96a796b
--- /dev/null
+++ b/src/AspireIntegrations/TemporalioSamples.SampleClient/TemporalioSamples.SampleClient.csproj
@@ -0,0 +1,13 @@
+
+
+
+ Exe
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorker/Program.cs b/src/AspireIntegrations/TemporalioSamples.SampleWorker/Program.cs
new file mode 100644
index 0000000..c5c71b5
--- /dev/null
+++ b/src/AspireIntegrations/TemporalioSamples.SampleWorker/Program.cs
@@ -0,0 +1,20 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+using Temporalio.Common.EnvConfig;
+using Temporalio.Extensions.Hosting;
+using TemporalioSamples.SampleWorkflow;
+
+var builder = Host.CreateApplicationBuilder(args);
+
+var connectOptions = ClientEnvConfig.LoadClientConnectOptions();
+
+builder.Services.AddHostedTemporalWorker(
+ clientTargetHost: connectOptions.TargetHost ?? "localhost:7233",
+ clientNamespace: connectOptions.Namespace,
+ taskQueue: "simple-task-queue")
+ .AddScopedActivities()
+ .AddWorkflow();
+
+var host = builder.Build();
+await host.RunAsync();
diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorker/TemporalioSamples.SampleWorker.csproj b/src/AspireIntegrations/TemporalioSamples.SampleWorker/TemporalioSamples.SampleWorker.csproj
new file mode 100644
index 0000000..9c84f21
--- /dev/null
+++ b/src/AspireIntegrations/TemporalioSamples.SampleWorker/TemporalioSamples.SampleWorker.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ enable
+ enable
+ SampleApp
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleActivities.cs b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleActivities.cs
new file mode 100644
index 0000000..3e8f178
--- /dev/null
+++ b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleActivities.cs
@@ -0,0 +1,15 @@
+using Microsoft.Extensions.Logging;
+using Temporalio.Activities;
+
+namespace TemporalioSamples.SampleWorkflow;
+
+public class SimpleActivities
+{
+ [Activity]
+ public async Task DoSomethingAsync()
+ {
+ ActivityExecutionContext.Current.Logger.LogInformation("Doing something async!");
+ await Task.Delay(TimeSpan.FromSeconds(2));
+ ActivityExecutionContext.Current.Logger.LogInformation("Done something async!");
+ }
+}
diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleWorkflow.cs b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleWorkflow.cs
new file mode 100644
index 0000000..42fd060
--- /dev/null
+++ b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/SimpleWorkflow.cs
@@ -0,0 +1,16 @@
+using Microsoft.Extensions.Logging;
+using Temporalio.Workflows;
+
+namespace TemporalioSamples.SampleWorkflow;
+
+[Workflow]
+public class SimpleWorkflow
+{
+ [WorkflowRun]
+ public async Task RunAsync()
+ {
+ Workflow.Logger.LogInformation("Starting workflow...");
+ await Workflow.ExecuteActivityAsync((SimpleActivities a) => a.DoSomethingAsync(), new() { StartToCloseTimeout = TimeSpan.FromMinutes(4) });
+ Workflow.Logger.LogInformation("Workflow completed!");
+ }
+}
diff --git a/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/TemporalioSamples.SampleWorkflow.csproj b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/TemporalioSamples.SampleWorkflow.csproj
new file mode 100644
index 0000000..9591b64
--- /dev/null
+++ b/src/AspireIntegrations/TemporalioSamples.SampleWorkflow/TemporalioSamples.SampleWorkflow.csproj
@@ -0,0 +1,7 @@
+
+
+
+ enable
+ enable
+
+
diff --git a/src/AspireIntegrations/aspire.config.json b/src/AspireIntegrations/aspire.config.json
new file mode 100644
index 0000000..6b536b3
--- /dev/null
+++ b/src/AspireIntegrations/aspire.config.json
@@ -0,0 +1,5 @@
+{
+ "appHost": {
+ "path": "TemporalioSamples.SampleAppHost/TemporalioSamples.SampleAppHost.csproj"
+ }
+}
diff --git a/tests/AspireIntegrations/TemporalCliServerResourceExtensionsTests.cs b/tests/AspireIntegrations/TemporalCliServerResourceExtensionsTests.cs
new file mode 100644
index 0000000..770aaff
--- /dev/null
+++ b/tests/AspireIntegrations/TemporalCliServerResourceExtensionsTests.cs
@@ -0,0 +1,165 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Temporal.Extensions.Aspire.Hosting;
+using Xunit;
+
+namespace TemporalioSamples.Tests.AspireIntegrations;
+
+public class TemporalCliServerResourceExtensionsTests
+{
+ // -----------------------------------------------------------------------
+ // TemporalCliLocator — PATH guard that backs AddTemporalCliServer
+ // -----------------------------------------------------------------------
+ [Fact]
+ public void EnsureAvailable_ThrowsInvalidOperationException_WhenCliNotFound()
+ {
+ // The isAvailable override lets tests simulate a machine without 'temporal' installed
+ // without manipulating the real PATH environment variable.
+ var ex = Assert.Throws(
+ () => TemporalCliLocator.EnsureAvailable(() => false));
+
+ Assert.Contains("temporal", ex.Message);
+ Assert.Contains("https://", ex.Message);
+ }
+
+ [Fact]
+ public void EnsureAvailable_DoesNotThrow_WhenCliIsFound()
+ {
+ // Should complete without exception when the check reports the CLI is present.
+ TemporalCliLocator.EnsureAvailable(() => true);
+ }
+
+ [Fact]
+ public void EnsureAvailable_ErrorMessage_ContainsInstallInstructions()
+ {
+ // Regression: the error must stay actionable — users must know where to get the CLI.
+ var ex = Assert.Throws(
+ () => TemporalCliLocator.EnsureAvailable(() => false));
+
+ Assert.Contains("docs.temporal.io/cli", ex.Message);
+ Assert.Contains("PATH", ex.Message);
+ }
+
+ // -----------------------------------------------------------------------
+ // AddTemporalCliServer — registration structure (PATH-independent)
+ //
+ // These tests use TemporalCliServerResource directly instead of calling
+ // AddTemporalCliServer so they do not depend on 'temporal' being installed
+ // on the test machine.
+ // -----------------------------------------------------------------------
+ [Fact]
+ public void TemporalCliServerResource_Command_IsTemporalExecutable()
+ {
+ // The resource MUST use "temporal" as the Aspire executable command.
+ // Regression guard: a rename here would silently break all CLI server setups
+ // because Aspire would try to launch the wrong binary.
+ var resource = new TemporalCliServerResource("temporal-cli-server");
+
+ Assert.Equal("temporal", resource.Command);
+ }
+
+ [Fact]
+ public void TemporalCliServerResource_DefaultArgs_ContainServerStartDev()
+ {
+ // Aspire launches the resource by appending args to the Command.
+ // The server subcommand must be present or temporal starts in the wrong mode.
+ var resource = new TemporalCliServerResource("temporal-cli-server");
+ var args = TemporalArgsBuilder.BuildArgs(resource.Options);
+
+ Assert.Contains("server", args);
+ Assert.Contains("start-dev", args);
+ }
+
+ [Fact]
+ public void TemporalCliServerResource_DefaultArgs_ContainServicePort()
+ {
+ // The --port flag must map to the default service port so the Aspire-registered
+ // endpoint matches the port temporal actually binds.
+ var resource = new TemporalCliServerResource("temporal-cli-server");
+ var args = TemporalArgsBuilder.BuildArgs(resource.Options);
+
+ var portIndex = Array.IndexOf(args, "--port");
+ Assert.True(portIndex >= 0, "Expected --port flag in args");
+ Assert.Equal(
+ TemporalResourceConstants.DefaultServiceEndpointPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ args[portIndex + 1]);
+ }
+
+ [Fact]
+ public void TemporalCliServerResource_DefaultArgs_ContainUiPort()
+ {
+ // Regression: UI port must be emitted in CLI mode so the dashboard is reachable.
+ // This was added alongside the fix that wires the UI endpoint URL in the Aspire dashboard.
+ var resource = new TemporalCliServerResource("temporal-cli-server");
+ var args = TemporalArgsBuilder.BuildArgs(resource.Options);
+
+ var uiPortIndex = Array.IndexOf(args, "--ui-port");
+ Assert.True(uiPortIndex >= 0, "Expected --ui-port flag in args");
+ Assert.Equal(
+ TemporalResourceConstants.DefaultUIEndpointPort.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ args[uiPortIndex + 1]);
+ }
+
+ [Fact]
+ public void TemporalCliServerResource_CustomOptions_ReflectedInArgs()
+ {
+ var resource = new TemporalCliServerResource("temporal-cli-server");
+ resource.Options.Namespace = "orders";
+ resource.Options.CodecEndpoint = "http://localhost:8088";
+
+ var args = TemporalArgsBuilder.BuildArgs(resource.Options);
+
+ Assert.Contains("orders", args);
+ Assert.Contains("http://localhost:8088", args);
+ }
+
+ // -----------------------------------------------------------------------
+ // AddTemporalCliServer — public path tests (PATH-independent via seam)
+ // -----------------------------------------------------------------------
+ [Fact]
+ public void AddTemporalCliServer_Throws_WhenTemporalCliNotOnPath()
+ {
+ // Regression guard: the public extension method must throw early with an
+ // actionable error when the Temporal CLI is absent, rather than failing later
+ // with a cryptic FailedToStart from Aspire. The isTemporalCliAvailable seam
+ // lets tests simulate a machine without 'temporal' on PATH.
+ var appBuilder = DistributedApplication.CreateBuilder([]);
+
+ var ex = Assert.Throws(
+ () => appBuilder.AddTemporalCliServer("temporal", configure: null, isTemporalCliAvailable: () => false));
+
+ Assert.Contains("temporal", ex.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("PATH", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void AddTemporalCliServer_RegistersExplicitPortsOnAllEndpoints()
+ {
+ // Regression: the CLI resource must register explicit host ports on all endpoints
+ // so the Aspire dashboard renders clickable URLs. Without an explicit port the
+ // dashboard assigns a random port and the link is either blank or wrong.
+ var appBuilder = DistributedApplication.CreateBuilder([]);
+
+ var resourceBuilder = appBuilder.AddTemporalCliServer(
+ "temporal",
+ configure: options =>
+ {
+ options.TargetHost = "0.0.0.0:17233";
+ options.UIPort = 18233;
+ options.MetricsPort = 19233;
+ },
+ isTemporalCliAvailable: () => true);
+
+ var annotations = resourceBuilder.Resource.Annotations
+ .OfType()
+ .ToList();
+
+ var service = annotations.Single(e => e.Name == TemporalResourceConstants.ServiceEndpointName);
+ var ui = annotations.Single(e => e.Name == TemporalResourceConstants.UIEndpointName);
+ var metrics = annotations.Single(e => e.Name == TemporalResourceConstants.MetricsEndpointName);
+
+ Assert.Equal(17233, service.Port);
+ Assert.Equal(18233, ui.Port);
+ Assert.Equal(19233, metrics.Port);
+ }
+}
diff --git a/tests/AspireIntegrations/TemporalEnvironmentHelperTests.cs b/tests/AspireIntegrations/TemporalEnvironmentHelperTests.cs
new file mode 100644
index 0000000..426bc83
--- /dev/null
+++ b/tests/AspireIntegrations/TemporalEnvironmentHelperTests.cs
@@ -0,0 +1,131 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Temporal.Extensions.Aspire.Hosting;
+using Xunit;
+
+namespace TemporalioSamples.Tests.AspireIntegrations;
+
+public class TemporalEnvironmentHelperTests
+{
+ [Fact]
+ public void AddEnvironmentVariables_DoesNotInjectDefaultNamespace()
+ {
+ var environmentVariables = new Dictionary();
+ var context = new EnvironmentCallbackContext(
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ environmentVariables,
+ CancellationToken.None);
+
+ var options = new TemporalResourceOptions
+ {
+ Namespace = "orders",
+ };
+
+ TemporalEnvironmentHelper.AddEnvironmentVariables(
+ context,
+ options,
+ "localhost:7233",
+ "http://localhost:8233");
+
+ Assert.Equal("localhost:7233", environmentVariables["TEMPORAL_ADDRESS"]);
+ Assert.Equal("http://localhost:8233", environmentVariables["TEMPORAL_UI_ADDRESS"]);
+ Assert.Equal("orders", environmentVariables["TEMPORAL_NAMESPACE"]);
+ Assert.False(environmentVariables.ContainsKey("TEMPORAL_DEFAULT_NAMESPACE"));
+ }
+
+ [Fact]
+ public void AddEnvironmentVariables_UsesIndependentContexts()
+ {
+ var firstVariables = new Dictionary();
+ var firstContext = new EnvironmentCallbackContext(
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ firstVariables,
+ CancellationToken.None);
+ var firstOptions = new TemporalResourceOptions { Namespace = "alpha" };
+
+ TemporalEnvironmentHelper.AddEnvironmentVariables(
+ firstContext,
+ firstOptions,
+ "alpha:7233",
+ "http://alpha:8233");
+
+ var secondVariables = new Dictionary();
+ var secondContext = new EnvironmentCallbackContext(
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ secondVariables,
+ CancellationToken.None);
+ var secondOptions = new TemporalResourceOptions { Namespace = "beta" };
+
+ TemporalEnvironmentHelper.AddEnvironmentVariables(
+ secondContext,
+ secondOptions,
+ "beta:7233",
+ "http://beta:8233");
+
+ Assert.Equal("alpha", firstVariables["TEMPORAL_NAMESPACE"]);
+ Assert.Equal("beta", secondVariables["TEMPORAL_NAMESPACE"]);
+ Assert.False(firstVariables.ContainsKey("TEMPORAL_DEFAULT_NAMESPACE"));
+ Assert.False(secondVariables.ContainsKey("TEMPORAL_DEFAULT_NAMESPACE"));
+ }
+
+ [Fact]
+ public void AddEnvironmentVariables_InjectsCodecAuth_WhenSet()
+ {
+ var environmentVariables = new Dictionary();
+ var context = new EnvironmentCallbackContext(
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ environmentVariables,
+ CancellationToken.None);
+
+ var options = new TemporalResourceOptions
+ {
+ Namespace = "default",
+ CodecAuth = "Bearer my-token",
+ };
+
+ TemporalEnvironmentHelper.AddEnvironmentVariables(
+ context, options, "localhost:7233", "http://localhost:8233");
+
+ Assert.Equal("Bearer my-token", environmentVariables["TEMPORAL_CODEC_AUTH"]);
+ }
+
+ [Fact]
+ public void AddEnvironmentVariables_InjectsCodecEndpoint_WhenSet()
+ {
+ var environmentVariables = new Dictionary();
+ var context = new EnvironmentCallbackContext(
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ environmentVariables,
+ CancellationToken.None);
+
+ var options = new TemporalResourceOptions
+ {
+ Namespace = "default",
+ CodecEndpoint = "http://localhost:8088",
+ };
+
+ TemporalEnvironmentHelper.AddEnvironmentVariables(
+ context, options, "localhost:7233", "http://localhost:8233");
+
+ Assert.Equal("http://localhost:8088", environmentVariables["TEMPORAL_CODEC_ENDPOINT"]);
+ }
+
+ [Fact]
+ public void AddEnvironmentVariables_OmitsCodecKeys_WhenNotSet()
+ {
+ // Regression: env vars must not be injected with null/empty values when codec is unused.
+ var environmentVariables = new Dictionary();
+ var context = new EnvironmentCallbackContext(
+ new DistributedApplicationExecutionContext(DistributedApplicationOperation.Run),
+ environmentVariables,
+ CancellationToken.None);
+
+ var options = new TemporalResourceOptions { Namespace = "default" };
+
+ TemporalEnvironmentHelper.AddEnvironmentVariables(
+ context, options, "localhost:7233", "http://localhost:8233");
+
+ Assert.False(environmentVariables.ContainsKey("TEMPORAL_CODEC_AUTH"));
+ Assert.False(environmentVariables.ContainsKey("TEMPORAL_CODEC_ENDPOINT"));
+ }
+}
diff --git a/tests/AspireIntegrations/TemporalHealthCheckHelperTests.cs b/tests/AspireIntegrations/TemporalHealthCheckHelperTests.cs
new file mode 100644
index 0000000..1deaed8
--- /dev/null
+++ b/tests/AspireIntegrations/TemporalHealthCheckHelperTests.cs
@@ -0,0 +1,47 @@
+using Aspire.Hosting;
+using Temporal.Extensions.Aspire.Hosting;
+using Xunit;
+
+namespace TemporalioSamples.Tests.AspireIntegrations;
+
+public class TemporalHealthCheckHelperTests
+{
+ [Fact]
+ public async Task AccessorReturnsNull_BeforeConnectionStringEventFires()
+ {
+ // Regression: the old design attempted to connect inside the ConnectionStringAvailableEvent
+ // callback and threw when the port wasn't bound yet, causing FailedToStart. The new design
+ // has the accessor return null (Unhealthy "not yet initialized") until the first successful
+ // connect, either during warm-up retries or on a later health probe.
+ //
+ // This test ensures that calling the accessor immediately after registration — before any
+ // Aspire event has fired — returns null rather than throwing.
+ var appBuilder = DistributedApplication.CreateBuilder([]);
+ var resource = new TemporalCliServerResource("test-temporal");
+
+ var accessor = TemporalHealthCheckHelper.RegisterCachedClientAccessor(
+ appBuilder, resource, "default");
+
+ var client = await accessor(CancellationToken.None);
+
+ Assert.Null(client);
+ }
+
+ [Fact]
+ public async Task AccessorIsIdempotent_WhenCalledMultipleTimes_BeforeEventFires()
+ {
+ // The accessor must be safe to call repeatedly (health checks probe on every interval).
+ // It must never throw when no connection string has been published yet.
+ var appBuilder = DistributedApplication.CreateBuilder([]);
+ var resource = new TemporalCliServerResource("test-temporal");
+
+ var accessor = TemporalHealthCheckHelper.RegisterCachedClientAccessor(
+ appBuilder, resource, "default");
+
+ for (var i = 0; i < 3; i++)
+ {
+ var client = await accessor(CancellationToken.None);
+ Assert.Null(client);
+ }
+ }
+}
diff --git a/tests/AspireIntegrations/TemporalHealthCheckTests.cs b/tests/AspireIntegrations/TemporalHealthCheckTests.cs
new file mode 100644
index 0000000..2d2a2ef
--- /dev/null
+++ b/tests/AspireIntegrations/TemporalHealthCheckTests.cs
@@ -0,0 +1,60 @@
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Temporal.Extensions.Aspire.Hosting;
+using Temporalio.Client;
+using Xunit;
+
+namespace TemporalioSamples.Tests.AspireIntegrations;
+
+public class TemporalHealthCheckTests
+{
+ [Fact]
+ public async Task CheckHealthAsync_ReturnsUnhealthy_WhenClientAccessorReturnsNull()
+ {
+ // Regression: before the lazy-reconnect refactor, a null client meant the resource stayed
+ // permanently Unhealthy with no path to recovery. The fix ensures the description is
+ // "not yet initialized" (a transient state) rather than a hard failure.
+ var healthCheck = new TemporalHealthCheck(_ => Task.FromResult(null));
+
+ var result = await healthCheck.CheckHealthAsync(MakeContext(healthCheck), CancellationToken.None);
+
+ Assert.Equal(HealthStatus.Unhealthy, result.Status);
+ Assert.Contains("not yet initialized", result.Description);
+ }
+
+ [Fact]
+ public async Task CheckHealthAsync_ReturnsUnhealthy_WhenAccessorAlwaysReturnsNull_StatusIsTransient()
+ {
+ // A second probe with a still-null client also returns Unhealthy, confirming the accessor
+ // is called on every probe (lazy, not just once).
+ var callCount = 0;
+ var healthCheck = new TemporalHealthCheck(_ =>
+ {
+ callCount++;
+ return Task.FromResult(null);
+ });
+
+ await healthCheck.CheckHealthAsync(MakeContext(healthCheck), CancellationToken.None);
+ await healthCheck.CheckHealthAsync(MakeContext(healthCheck), CancellationToken.None);
+
+ Assert.Equal(2, callCount);
+ }
+
+ [Fact]
+ public async Task CheckHealthAsync_PropagatesCancellation_WhenTokenCancelled()
+ {
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ var healthCheck = new TemporalHealthCheck(ct =>
+ {
+ ct.ThrowIfCancellationRequested();
+ return Task.FromResult(null);
+ });
+
+ await Assert.ThrowsAnyAsync(
+ () => healthCheck.CheckHealthAsync(MakeContext(healthCheck), cts.Token));
+ }
+
+ private static HealthCheckContext MakeContext(IHealthCheck instance) =>
+ new() { Registration = new HealthCheckRegistration("temporal", instance, HealthStatus.Unhealthy, null) };
+}
diff --git a/tests/AspireIntegrations/TemporalResourceOptionsTests.cs b/tests/AspireIntegrations/TemporalResourceOptionsTests.cs
new file mode 100644
index 0000000..2792a4f
--- /dev/null
+++ b/tests/AspireIntegrations/TemporalResourceOptionsTests.cs
@@ -0,0 +1,88 @@
+using Temporal.Extensions.Aspire.Hosting;
+using Xunit;
+
+namespace TemporalioSamples.Tests.AspireIntegrations;
+
+public class TemporalResourceOptionsTests
+{
+ [Fact]
+ public void DefaultOptions_HaveExpectedPortValues()
+ {
+ var options = new TemporalResourceOptions();
+
+ Assert.Equal(TemporalResourceConstants.DefaultServiceEndpointPort, options.Port);
+ Assert.Equal(TemporalResourceConstants.DefaultUIEndpointPort, options.UIPort);
+ Assert.Equal(TemporalResourceConstants.DefaultMetricsEndpointPort, options.MetricsPort);
+ }
+
+ [Fact]
+ public void DefaultOptions_HaveUIEnabled()
+ {
+ var options = new TemporalResourceOptions();
+
+ Assert.True(options.UI);
+ Assert.False(options.IsHeadless);
+ }
+
+ [Fact]
+ public void Port_ParsedCorrectlyFromTargetHost()
+ {
+ var options = new TemporalResourceOptions { TargetHost = "0.0.0.0:7240" };
+
+ Assert.Equal(7240, options.Port);
+ }
+
+ [Fact]
+ public void Ip_ParsedCorrectlyFromTargetHost()
+ {
+ var options = new TemporalResourceOptions { TargetHost = "127.0.0.1:7233" };
+
+ Assert.Equal("127.0.0.1", options.Ip);
+ }
+
+ [Fact]
+ public void Port_ThrowsInvalidOperationException_WhenTargetHostIsEmpty()
+ {
+ var options = new TemporalResourceOptions { TargetHost = string.Empty };
+
+ Assert.Throws(() => _ = options.Port);
+ }
+
+ [Fact]
+ public void Ip_ThrowsInvalidOperationException_WhenTargetHostIsEmpty()
+ {
+ var options = new TemporalResourceOptions { TargetHost = string.Empty };
+
+ Assert.Throws(() => _ = options.Ip);
+ }
+
+ [Fact]
+ public void Port_ThrowsInvalidOperationException_WhenTargetHostLacksPort()
+ {
+ // Regression: TargetHost with no colon-separated port must not silently return 0.
+ var options = new TemporalResourceOptions { TargetHost = "localhost" };
+
+ Assert.Throws(() => _ = options.Port);
+ }
+
+ [Fact]
+ public void AdditionalNamespaces_AlwaysIncludesPrimaryNamespace()
+ {
+ var options = new TemporalResourceOptions { Namespace = "orders" };
+ options.AdditionalNamespaces = new List { "shipping", "payments" };
+
+ Assert.Contains("orders", options.AdditionalNamespaces);
+ Assert.Contains("shipping", options.AdditionalNamespaces);
+ Assert.Contains("payments", options.AdditionalNamespaces);
+ }
+
+ [Fact]
+ public void AdditionalNamespaces_DeduplicatesPrimaryNamespace()
+ {
+ // Setting the primary namespace in the extra list must not create a duplicate.
+ var options = new TemporalResourceOptions { Namespace = "orders" };
+ options.AdditionalNamespaces = new List { "orders", "shipping" };
+
+ Assert.Single(options.AdditionalNamespaces, ns => ns == "orders");
+ }
+}
diff --git a/tests/TemporalioSamples.Tests.csproj b/tests/TemporalioSamples.Tests.csproj
index 5893aca..0505ca4 100644
--- a/tests/TemporalioSamples.Tests.csproj
+++ b/tests/TemporalioSamples.Tests.csproj
@@ -22,6 +22,7 @@
+