Skip to content

Add project-defined startup forms and commands using aspire interaction service#8

Open
oising wants to merge 4 commits intomainfrom
aspirify
Open

Add project-defined startup forms and commands using aspire interaction service#8
oising wants to merge 4 commits intomainfrom
aspirify

Conversation

@oising
Copy link
Owner

@oising oising commented Feb 5, 2026

No description provided.

@oising oising self-assigned this Feb 5, 2026
@oising oising added the enhancement New feature or request label Feb 5, 2026
@oising oising marked this pull request as ready for review February 5, 2026 22:42
Copilot AI review requested due to automatic review settings February 5, 2026 22:42
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds significant new functionality to the Aspire Project Commander library, enabling projects to define their own commands and startup forms through JSON manifests. The changes introduce a declarative approach where projects can specify interactive configuration requirements via a projectcommander.json file, complementing the existing code-based command registration.

Changes:

  • Added project-defined commands via JSON manifest with schema validation
  • Implemented startup forms that block project execution until user provides configuration
  • Updated package dependencies from Aspire 9.x/ASP.NET 9.x to 13.x/10.x versions
  • Refactored DI registration to avoid anti-pattern of calling BuildServiceProvider during registration

Reviewed changes

Copilot reviewed 27 out of 28 changed files in this pull request and generated 20 comments.

Show a summary per file
File Description
schemas/projectcommander-v1.schema.json JSON schema defining manifest structure for commands and startup forms
Src/Nivot.Aspire.Hosting.ProjectCommander/StartupFormAnnotation.cs Resource annotation tracking startup form state and completion
Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommandManifest.cs Model types for deserializing manifest JSON
Src/Nivot.Aspire.Hosting.ProjectCommander/ManifestReader.cs Manifest parsing and conversion to Aspire InteractionInput types
Src/Nivot.Aspire.Hosting.ProjectCommander/ResourceBuilderProjectCommanderExtensions.cs Added WithProjectManifest method and startup form command registration
Src/Nivot.Aspire.Hosting.ProjectCommander/ProjectCommanderHub.cs Hub methods for startup form lifecycle management
Src/Nivot.Aspire.ProjectCommander/IAspireProjectCommanderClient.cs Extended interface with startup form support and command arguments
Src/Nivot.Aspire.ProjectCommander/AspireProjectCommanderClientWorker.cs Implemented startup form handling and blocking until completion
Src/Nivot.Aspire.ProjectCommander/ServiceCollectionAspireProjectCommanderExtensions.cs Refactored DI registration to avoid BuildServiceProvider anti-pattern
Sample/DataGenerator/projectcommander.json Example manifest demonstrating startup forms and parameterized commands
Sample/DataGenerator/Program.cs Updated to use startup form data and handle command arguments
README.md Comprehensive documentation of new manifest and startup form features
CHANGELOG.md Detailed changelog documenting breaking changes and new features
Multiple .csproj files Package version updates across the solution

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

break;
case "specify":
logger.LogInformation("Specify command received with args {Args}", string.Join(", ", args));
period = TimeSpan.FromSeconds(int.Parse(args[0]));
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential format exception if args[0] cannot be parsed to an integer. Consider using int.TryParse instead of int.Parse to handle invalid input gracefully and log an appropriate error message.

Suggested change
period = TimeSpan.FromSeconds(int.Parse(args[0]));
if (args.Length == 0)
{
logger.LogWarning("Specify command requires a numeric argument, but no arguments were provided.");
break;
}
if (!int.TryParse(args[0], out var seconds))
{
logger.LogWarning("Specify command argument '{Argument}' is not a valid integer number of seconds.", args[0]);
break;
}
period = TimeSpan.FromSeconds(seconds);

Copilot uses AI. Check for mistakes.

if (result.Canceled)
{
return new ExecuteCommandResult() { Success = true, ErrorMessage = "User cancelled command." };
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same cancellation handling issue exists here. When result.Canceled is true, the method returns Success = true with ErrorMessage = "User cancelled command." This is semantically confusing - cancellation should not be treated as a successful operation with an error message.

Suggested change
return new ExecuteCommandResult() { Success = true, ErrorMessage = "User cancelled command." };
return new ExecuteCommandResult() { Success = false, ErrorMessage = "User cancelled command." };

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +144
// Build form data dictionary
var formData = new Dictionary<string, string?>();
for (var i = 0; i < inputs.Length; i++)
{
formData[inputs[i].Name] = result.Data[i].Value;
}

// Send form data to the project
var groupName = context.ResourceName;
await hubResource.Hub.Clients.Group(groupName).SendAsync(
"ReceiveStartupForm",
formData,
context.CancellationToken);
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The startup form inputs are passed directly to the project without any server-side validation beyond what the client enforces. Consider adding validation logic on the AppHost side to ensure required fields are present and meet constraints (e.g., maxLength, valid choice options) before sending the data to the project, as client-side validation can be bypassed.

Copilot uses AI. Check for mistakes.
/// </summary>
public event Func<string, IServiceProvider, Task> CommandReceived;
} No newline at end of file
public event Func<string, string[], IServiceProvider, Task> CommandReceived;
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CommandReceived event signature changed from (string, IServiceProvider, Task) to (string, string[], IServiceProvider, Task), adding a new args parameter in the middle. This is a breaking change that will require all existing event handlers to be updated. Consider documenting this breaking change prominently in the CHANGELOG and migration guide, and ensure the major version bump (2.0.0) is justified.

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +201
public async Task<Dictionary<string, string?>?> WaitForStartupFormAsync(CancellationToken cancellationToken = default)
{
// If no startup form is required, return immediately
if (!_isStartupFormRequired)
{
logger.LogDebug("No startup form required, continuing immediately");
return null;
}

// If already completed, return the data
if (_isStartupFormCompleted && _startupFormData != null)
{
return _startupFormData;
}

logger.LogInformation("Waiting for startup form to be completed...");

// Wait for the form to be completed
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

var completedTask = await Task.WhenAny(
_startupFormCompletionSource.Task,
Task.Delay(Timeout.Infinite, cts.Token));

if (completedTask == _startupFormCompletionSource.Task)
{
return await _startupFormCompletionSource.Task;
}

// Cancelled
cancellationToken.ThrowIfCancellationRequested();
return null;
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new startup form functionality lacks test coverage. Consider adding tests to verify: 1) Startup form data is correctly transmitted from AppHost to project, 2) WaitForStartupFormAsync blocks until form completion, 3) Form validation and error handling work correctly, 4) Resource name parsing in ProjectCommanderHub works with various formats. The existing test file only contains placeholder comments.

Copilot uses AI. Check for mistakes.
Comment on lines +186 to +200
// Wait for the form to be completed
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

var completedTask = await Task.WhenAny(
_startupFormCompletionSource.Task,
Task.Delay(Timeout.Infinite, cts.Token));

if (completedTask == _startupFormCompletionSource.Task)
{
return await _startupFormCompletionSource.Task;
}

// Cancelled
cancellationToken.ThrowIfCancellationRequested();
return null;
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WaitForStartupFormAsync method has a logical issue: if cancellation is requested, the code creates a linked token source and waits on Task.Delay(Timeout.Infinite), which will never complete unless cancelled. However, when cancelled, line 199 throws, but line 200 attempts to return null which is unreachable. Consider simplifying this logic to directly await the TaskCompletionSource.Task with the cancellation token, which will throw OperationCanceledException when cancelled.

Suggested change
// Wait for the form to be completed
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var completedTask = await Task.WhenAny(
_startupFormCompletionSource.Task,
Task.Delay(Timeout.Infinite, cts.Token));
if (completedTask == _startupFormCompletionSource.Task)
{
return await _startupFormCompletionSource.Task;
}
// Cancelled
cancellationToken.ThrowIfCancellationRequested();
return null;
// Wait for the form to be completed, honoring cancellation
return await _startupFormCompletionSource.Task.WaitAsync(cancellationToken);

Copilot uses AI. Check for mistakes.
Comment on lines +28 to +30
services.AddSingleton<AspireProjectCommanderClientWorker>();
services.AddSingleton<IHostedService>(sp => sp.GetRequiredService<AspireProjectCommanderClientWorker>());
services.AddSingleton<IAspireProjectCommanderClient>(sp => sp.GetRequiredService<AspireProjectCommanderClientWorker>());
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refactored DI registration allows registering the service multiple times. If AddAspireProjectCommanderClient is called twice, it will register duplicate singleton services, which could lead to multiple instances of AspireProjectCommanderClientWorker being created. Consider checking if the service is already registered (e.g., using TryAddSingleton) or documenting that this method should only be called once.

Copilot uses AI. Check for mistakes.
// Notify the hub of completion
try
{
await _hub.InvokeAsync("StartupFormCompleted", _aspireResourceName, success, errorMessage, stoppingToken);
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The startup form completion callback at line 102 passes _aspireResourceName which could be null (it's a nullable field). Add a null check before calling InvokeAsync or ensure _aspireResourceName is assigned before the hub connection handlers are registered.

Suggested change
await _hub.InvokeAsync("StartupFormCompleted", _aspireResourceName, success, errorMessage, stoppingToken);
if (_aspireResourceName is not null)
{
await _hub.InvokeAsync("StartupFormCompleted", _aspireResourceName, success, errorMessage, stoppingToken);
}
else
{
logger.LogWarning("Cannot notify hub of startup form completion because Aspire resource name is not set.");
}

Copilot uses AI. Check for mistakes.
await Groups.AddToGroupAsync(Context.ConnectionId, resourceName);

// Check if this resource has a startup form and notify the client
var baseResourceName = resourceName.Split('-')[0];
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resource name parsing using Split('-')[0] is fragile and could fail if the resource name contains multiple hyphens or doesn't follow the expected format. Consider using a more robust parsing method, such as LastIndexOf('-') to get the suffix, or document the exact format expected. This affects the hub's ability to correctly identify resources with startup forms.

Copilot uses AI. Check for mistakes.
Comment on lines +73 to +74
period = TimeSpan.FromSeconds(int.Parse(args[0]));
logger.LogInformation("Period was set to {Period}", period);
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential array index out of bounds exception if args array is empty when parsing args[0]. Add validation to check that args.Length > 0 before accessing args[0], or handle the IndexOutOfRangeException appropriately.

Suggested change
period = TimeSpan.FromSeconds(int.Parse(args[0]));
logger.LogInformation("Period was set to {Period}", period);
if (args.Length > 0 && int.TryParse(args[0], out var seconds))
{
period = TimeSpan.FromSeconds(seconds);
logger.LogInformation("Period was set to {Period}", period);
}
else
{
logger.LogWarning("Specify command received without a valid numeric argument. Period remains {Period}", period);
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant