From 6cb86071a2542c8fcd0df2c8a33def1873f713ff Mon Sep 17 00:00:00 2001 From: Nick Beaugeard Date: Wed, 29 Apr 2026 15:37:40 +1000 Subject: [PATCH] Add workflow profile editor --- docs/feature_guides.md | 8 + docs/user_guide.md | 10 + .../Workflows/IWorkflowProfileService.cs | 81 ++ .../Domain/Symphony/SymphonyInstance.cs | 15 +- .../Domain/Workflows/WorkflowProfile.cs | 123 +- .../Components/Layout/MainLayout.razor | 1 + .../Components/Pages/Repositories.razor | 29 + .../Components/Pages/WorkflowProfiles.razor | 365 ++++++ src/Conductor.Host/wwwroot/app.css | 178 ++- .../StronglyTypedIdValueConverters.cs | 4 + .../SymphonyInstanceConfiguration.cs | 10 + .../WorkflowProfileConfiguration.cs | 14 + ...1111_AddWorkflowProfileEditing.Designer.cs | 1009 +++++++++++++++++ ...0260429051111_AddWorkflowProfileEditing.cs | 110 ++ .../ConductorDbContextModelSnapshot.cs | 25 + .../SqliteRepositoryImportService.cs | 3 +- ...ePersistenceServiceCollectionExtensions.cs | 3 + .../Workflows/SqliteWorkflowProfileService.cs | 334 ++++++ .../RepositoriesPageTests.cs | 45 + .../WorkflowProfilesPageTests.cs | 184 +++ .../Issue13DomainEntityTests.cs | 33 + .../SqliteRepositoryImportServiceTests.cs | 14 + .../SqliteWorkflowProfileServiceTests.cs | 163 +++ 23 files changed, 2734 insertions(+), 27 deletions(-) create mode 100644 src/Conductor.Core/Application/Workflows/IWorkflowProfileService.cs create mode 100644 src/Conductor.Host/Components/Pages/WorkflowProfiles.razor create mode 100644 src/Conductor.Infrastructure.Persistence.Sqlite/Migrations/20260429051111_AddWorkflowProfileEditing.Designer.cs create mode 100644 src/Conductor.Infrastructure.Persistence.Sqlite/Migrations/20260429051111_AddWorkflowProfileEditing.cs create mode 100644 src/Conductor.Infrastructure.Persistence.Sqlite/Workflows/SqliteWorkflowProfileService.cs create mode 100644 tests/Conductor.Blazor.Tests/WorkflowProfilesPageTests.cs create mode 100644 tests/Conductor.Persistence.Tests/SqliteWorkflowProfileServiceTests.cs diff --git a/docs/feature_guides.md b/docs/feature_guides.md index 11d36dd..6f46baf 100644 --- a/docs/feature_guides.md +++ b/docs/feature_guides.md @@ -33,3 +33,11 @@ When no repository projection data exists, the dashboard renders an empty state. The secret descriptor list supports GitHub PAT and OpenAI API key credentials as separate types. Descriptor rows show the credential name, scope, target environment variable, and a masked value only. OpenAI API key descriptors map to `OPENAI_API_KEY`; GitHub PAT descriptors map to `GITHUB_TOKEN`. Descriptors may include validation status, validation timestamp, a short validation message, and validation metadata JSON such as accepted token prefixes and the runtime environment variable used for injection. Plaintext token values are only accepted during create or rotate workflows and must not be returned in descriptor responses. + +## Workflow Profile Management + +Workflow profiles are managed from `/settings/workflows`. Operators can create a profile, edit an existing profile, mark one profile as the default, and preview the raw `WORKFLOW.md` source before saving. + +Each save increments the profile revision when profile content or default state changes. When a profile becomes the default, Conductor clears the default flag from any previous default profile in the same transaction. + +The Repositories page uses the saved profile list when creating a Symphony instance shell. Selecting a profile persists the `WorkflowProfileId` on the instance record for later workflow generation and validation. diff --git a/docs/user_guide.md b/docs/user_guide.md index 8d7cf5a..acc5135 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -28,4 +28,14 @@ When a project is selected during import, the repository is linked immediately a The page can also create a first Symphony instance shell in `NotProvisioned` state. The shell captures execution mode, instance URL, port, release selector, and credential inheritance choices so later provisioning work can start from a validated record. +If workflow profiles exist, choose one while creating the instance shell. Conductor stores that profile reference on the instance so later workflow generation can render the correct `WORKFLOW.md` source. + Imported repositories appear in the managed repository registry with project, visibility, default branch, archive state, orchestration eligibility, instance counts, last sync, and latest health metadata. Open a repository row to review its detail page, including clone and web URLs, sync metadata, orchestration status, and active Symphony instances attached to that repository. + +## Workflow Profiles + +Use the Workflows page to create and edit reusable `WORKFLOW.md` profiles. Each profile stores a name, optional description, raw Markdown source, default flag, revision number, and created/updated timestamps. + +Mark one profile as the default when it should be the standard choice for new Symphony instance shells. Setting a profile as default clears the previous default profile. + +The editor shows the current source beside the form before save. Saving a profile validates required fields and preserves an audit event for create and update operations. diff --git a/src/Conductor.Core/Application/Workflows/IWorkflowProfileService.cs b/src/Conductor.Core/Application/Workflows/IWorkflowProfileService.cs new file mode 100644 index 0000000..1b9b2e4 --- /dev/null +++ b/src/Conductor.Core/Application/Workflows/IWorkflowProfileService.cs @@ -0,0 +1,81 @@ +using Conductor.Core.Domain.Ids; + +namespace Conductor.Core.Application.Workflows; + +public interface IWorkflowProfileService +{ + Task> ListAsync( + WorkflowProfileQuery query, + CancellationToken cancellationToken = default); + + Task GetAsync( + WorkflowProfileId profileId, + CancellationToken cancellationToken = default); + + Task CreateAsync( + WorkflowProfileMutationRequest request, + CancellationToken cancellationToken = default); + + Task UpdateAsync( + WorkflowProfileId profileId, + WorkflowProfileMutationRequest request, + CancellationToken cancellationToken = default); +} + +public sealed record WorkflowProfileQuery( + string? Search = null); + +public sealed record WorkflowProfileSummary( + WorkflowProfileId Id, + string Name, + string? Description, + bool IsDefault, + int Revision, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc); + +public sealed record WorkflowProfileDetail( + WorkflowProfileId Id, + string Name, + string? Description, + string WorkflowSource, + bool IsDefault, + int Revision, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc); + +public sealed record WorkflowProfileMutationRequest( + string Name, + string? Description, + string WorkflowSource, + bool IsDefault = false, + string? RequestedByUserId = null); + +public sealed record WorkflowProfileMutationResult( + WorkflowProfileId Id, + string Name, + bool IsDefault, + int Revision, + DateTimeOffset UpdatedAtUtc); + +public sealed class WorkflowProfileValidationException : Exception +{ + public WorkflowProfileValidationException(IReadOnlyDictionary errors) + : base("Workflow profile failed validation.") + { + Errors = errors; + } + + public IReadOnlyDictionary Errors { get; } +} + +public sealed class WorkflowProfileNotFoundException : Exception +{ + public WorkflowProfileNotFoundException(WorkflowProfileId profileId) + : base($"Workflow profile '{profileId}' was not found.") + { + ProfileId = profileId; + } + + public WorkflowProfileId ProfileId { get; } +} diff --git a/src/Conductor.Core/Domain/Symphony/SymphonyInstance.cs b/src/Conductor.Core/Domain/Symphony/SymphonyInstance.cs index bf72997..556a9e5 100644 --- a/src/Conductor.Core/Domain/Symphony/SymphonyInstance.cs +++ b/src/Conductor.Core/Domain/Symphony/SymphonyInstance.cs @@ -28,7 +28,8 @@ public SymphonyInstance( string? workflowPath = null, string? dataPath = null, DateTimeOffset? lastStartedAtUtc = null, - DateTimeOffset? lastSeenAtUtc = null) + DateTimeOffset? lastSeenAtUtc = null, + WorkflowProfileId? workflowProfileId = null) { Id = new SymphonyInstanceId(Guard.NotEmpty(id.Value, nameof(id))); RepositoryId = new RepositoryId(Guard.NotEmpty(repositoryId.Value, nameof(repositoryId))); @@ -57,6 +58,9 @@ public SymphonyInstance( OpenAiCredentialInheritanceMode = openAiCredentialInheritanceMode; WorkflowPath = Guard.OptionalTrimmed(workflowPath); DataPath = Guard.OptionalTrimmed(dataPath); + WorkflowProfileId = workflowProfileId is null + ? null + : new WorkflowProfileId(Guard.NotEmpty(workflowProfileId.Value.Value, nameof(workflowProfileId))); LastStartedAtUtc = ValidateObservedAt(lastStartedAtUtc, nameof(lastStartedAtUtc)); LastSeenAtUtc = ValidateObservedAt(lastSeenAtUtc, nameof(lastSeenAtUtc)); } @@ -101,6 +105,8 @@ public SymphonyInstance( public string? DataPath { get; private set; } + public WorkflowProfileId? WorkflowProfileId { get; private set; } + public DateTimeOffset CreatedAtUtc { get; } public DateTimeOffset? LastStartedAtUtc { get; private set; } @@ -175,6 +181,13 @@ public void ConfigurePaths(string? workflowPath, string? dataPath) DataPath = Guard.OptionalTrimmed(dataPath); } + public void AssignWorkflowProfile(WorkflowProfileId? workflowProfileId) + { + WorkflowProfileId = workflowProfileId is null + ? null + : new WorkflowProfileId(Guard.NotEmpty(workflowProfileId.Value.Value, nameof(workflowProfileId))); + } + public void MarkLifecycle(InstanceLifecycleStatus lifecycleStatus) { LifecycleStatus = lifecycleStatus; diff --git a/src/Conductor.Core/Domain/Workflows/WorkflowProfile.cs b/src/Conductor.Core/Domain/Workflows/WorkflowProfile.cs index fa9e88c..322625a 100644 --- a/src/Conductor.Core/Domain/Workflows/WorkflowProfile.cs +++ b/src/Conductor.Core/Domain/Workflows/WorkflowProfile.cs @@ -10,18 +10,133 @@ public WorkflowProfile( string name, string workflowSource, DateTimeOffset createdAtUtc) + : this( + id, + name, + description: null, + workflowSource, + isDefault: false, + createdAtUtc, + updatedAtUtc: createdAtUtc, + revision: 1) { - Id = id; + } + + public WorkflowProfile( + WorkflowProfileId id, + string name, + string? description, + string workflowSource, + bool isDefault, + DateTimeOffset createdAtUtc, + DateTimeOffset updatedAtUtc, + int revision = 1) + { + if (revision < 1) + { + throw new ArgumentOutOfRangeException(nameof(revision), revision, "Workflow profile revision must be at least 1."); + } + + Id = new WorkflowProfileId(Guard.NotEmpty(id.Value, nameof(id))); Name = Guard.NotWhiteSpace(name, nameof(name)); + Description = Guard.OptionalTrimmed(description); WorkflowSource = Guard.NotWhiteSpace(workflowSource, nameof(workflowSource)); - CreatedAtUtc = createdAtUtc; + IsDefault = isDefault; + CreatedAtUtc = Guard.Utc(createdAtUtc, nameof(createdAtUtc)); + UpdatedAtUtc = Guard.Utc(updatedAtUtc, nameof(updatedAtUtc)); + Revision = revision; + + if (UpdatedAtUtc < CreatedAtUtc) + { + throw new ArgumentException("Updated timestamp cannot be earlier than created timestamp.", nameof(updatedAtUtc)); + } } public WorkflowProfileId Id { get; } - public string Name { get; } + public string Name { get; private set; } + + public string? Description { get; private set; } - public string WorkflowSource { get; } + public string WorkflowSource { get; private set; } + + public bool IsDefault { get; private set; } + + public int Revision { get; private set; } public DateTimeOffset CreatedAtUtc { get; } + + public DateTimeOffset UpdatedAtUtc { get; private set; } + + public void Update( + string name, + string? description, + string workflowSource, + bool isDefault, + DateTimeOffset updatedAtUtc) + { + var validatedName = Guard.NotWhiteSpace(name, nameof(name)); + var validatedDescription = Guard.OptionalTrimmed(description); + var validatedWorkflowSource = Guard.NotWhiteSpace(workflowSource, nameof(workflowSource)); + var validatedUpdatedAtUtc = ValidateUpdatedAt(updatedAtUtc); + + if (Name == validatedName && + Description == validatedDescription && + WorkflowSource == validatedWorkflowSource && + IsDefault == isDefault) + { + return; + } + + Name = validatedName; + Description = validatedDescription; + WorkflowSource = validatedWorkflowSource; + IsDefault = isDefault; + RecordRevision(validatedUpdatedAtUtc); + } + + public void MarkDefault(DateTimeOffset updatedAtUtc) + { + if (IsDefault) + { + return; + } + + IsDefault = true; + RecordRevision(ValidateUpdatedAt(updatedAtUtc)); + } + + public void ClearDefault(DateTimeOffset updatedAtUtc) + { + if (!IsDefault) + { + return; + } + + IsDefault = false; + RecordRevision(ValidateUpdatedAt(updatedAtUtc)); + } + + private void RecordRevision(DateTimeOffset updatedAtUtc) + { + UpdatedAtUtc = updatedAtUtc; + Revision++; + } + + private DateTimeOffset ValidateUpdatedAt(DateTimeOffset updatedAtUtc) + { + var utcUpdatedAt = Guard.Utc(updatedAtUtc, nameof(updatedAtUtc)); + + if (utcUpdatedAt < CreatedAtUtc) + { + throw new ArgumentException("Updated timestamp cannot be earlier than created timestamp.", nameof(updatedAtUtc)); + } + + if (utcUpdatedAt < UpdatedAtUtc) + { + throw new ArgumentException("Updated timestamp cannot move backwards.", nameof(updatedAtUtc)); + } + + return utcUpdatedAt; + } } diff --git a/src/Conductor.Host/Components/Layout/MainLayout.razor b/src/Conductor.Host/Components/Layout/MainLayout.razor index 88b5308..1c909fd 100644 --- a/src/Conductor.Host/Components/Layout/MainLayout.razor +++ b/src/Conductor.Host/Components/Layout/MainLayout.razor @@ -11,6 +11,7 @@ Projects Repositories Instances + Workflows Secrets Reports diff --git a/src/Conductor.Host/Components/Pages/Repositories.razor b/src/Conductor.Host/Components/Pages/Repositories.razor index 4b58227..d626fdc 100644 --- a/src/Conductor.Host/Components/Pages/Repositories.razor +++ b/src/Conductor.Host/Components/Pages/Repositories.razor @@ -2,11 +2,13 @@ @rendermode InteractiveServer @using Conductor.Core.Application.Queries @using Conductor.Core.Application.Repositories +@using Conductor.Core.Application.Workflows @using Conductor.Core.Domain @using Conductor.Core.Domain.Ids @inject IRepositoryImportService RepositoryImportService @inject IRepositoryListQueryService RepositoryListQueryService @inject IProjectListQueryService ProjectListQueryService +@inject IWorkflowProfileService WorkflowProfileService Repositories - Conductor @@ -117,6 +119,16 @@ } +