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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/feature_guides.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
10 changes: 10 additions & 0 deletions docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using Conductor.Core.Domain.Ids;

namespace Conductor.Core.Application.Workflows;

public interface IWorkflowProfileService
{
Task<IReadOnlyList<WorkflowProfileSummary>> ListAsync(
WorkflowProfileQuery query,
CancellationToken cancellationToken = default);

Task<WorkflowProfileDetail?> GetAsync(
WorkflowProfileId profileId,
CancellationToken cancellationToken = default);

Task<WorkflowProfileMutationResult> CreateAsync(
WorkflowProfileMutationRequest request,
CancellationToken cancellationToken = default);

Task<WorkflowProfileMutationResult> 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<string, string[]> errors)
: base("Workflow profile failed validation.")
{
Errors = errors;
}

public IReadOnlyDictionary<string, string[]> Errors { get; }
}

public sealed class WorkflowProfileNotFoundException : Exception
{
public WorkflowProfileNotFoundException(WorkflowProfileId profileId)
: base($"Workflow profile '{profileId}' was not found.")
{
ProfileId = profileId;
}

public WorkflowProfileId ProfileId { get; }
}
15 changes: 14 additions & 1 deletion src/Conductor.Core/Domain/Symphony/SymphonyInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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;
Expand Down
123 changes: 119 additions & 4 deletions src/Conductor.Core/Domain/Workflows/WorkflowProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
1 change: 1 addition & 0 deletions src/Conductor.Host/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<NavLink href="/projects">Projects</NavLink>
<NavLink href="/repositories">Repositories</NavLink>
<NavLink href="/instances">Instances</NavLink>
<NavLink href="/settings/workflows">Workflows</NavLink>
<NavLink href="/settings/secrets">Secrets</NavLink>
<NavLink href="/reports">Reports</NavLink>
</nav>
Expand Down
29 changes: 29 additions & 0 deletions src/Conductor.Host/Components/Pages/Repositories.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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

<PageTitle>Repositories - Conductor</PageTitle>

Expand Down Expand Up @@ -117,6 +119,16 @@
}
</select>
</label>
<label>
<span>Workflow profile</span>
<select id="workflow-profile-id" @bind="importForm.WorkflowProfileId" disabled="@isSubmitting">
<option value="">Unassigned</option>
@foreach (WorkflowProfileSummary profile in workflowProfiles)
{
<option value="@profile.Id.ToString()">@FormatWorkflowProfileOption(profile)</option>
}
</select>
</label>
<label>
<span>Port</span>
<input
Expand Down Expand Up @@ -269,6 +281,7 @@
private readonly RepositoryImportForm importForm = new();
private IReadOnlyList<RepositoryListItemProjection> repositories = [];
private IReadOnlyList<ProjectListItemProjection> projects = [];
private IReadOnlyList<WorkflowProfileSummary> workflowProfiles = [];
private readonly List<string> validationMessages = [];
private RepositoryImportResult? importResult;
private string? importError;
Expand Down Expand Up @@ -316,6 +329,12 @@
projectId = parsedProjectId;
}

WorkflowProfileId? workflowProfileId = null;
if (WorkflowProfileId.TryParse(importForm.WorkflowProfileId, out WorkflowProfileId parsedWorkflowProfileId))
{
workflowProfileId = parsedWorkflowProfileId;
}

return new RepositoryImportRequest(
importForm.RepositoryFullName,
importForm.DefaultBranch,
Expand All @@ -328,6 +347,7 @@
InstanceBaseUrl: importForm.InstanceBaseUrl,
Port: importForm.Port,
ReleaseTag: importForm.ReleaseTag,
WorkflowProfileId: workflowProfileId,
GitHubCredentialInheritanceMode: ParseEnum<CredentialInheritanceMode>(importForm.GitHubCredentialMode),
OpenAiCredentialInheritanceMode: ParseEnum<CredentialInheritanceMode>(importForm.OpenAiCredentialMode),
RequestedByUserId: "ui");
Expand All @@ -341,6 +361,7 @@
try
{
projects = await ProjectListQueryService.ListProjectsAsync(new ProjectListQuery());
workflowProfiles = await WorkflowProfileService.ListAsync(new WorkflowProfileQuery());
repositories = await RepositoryListQueryService.ListRepositoriesAsync(new RepositoryListQuery());
}
catch (Exception ex) when (ex is InvalidOperationException or IOException)
Expand All @@ -356,6 +377,11 @@
private static string RepositoryHref(RepositoryListItemProjection repository) =>
$"/repositories/{repository.Id.Value:D}";

private static string FormatWorkflowProfileOption(WorkflowProfileSummary profile) =>
profile.IsDefault
? $"{profile.Name} (default)"
: profile.Name;

private static TEnum ParseEnum<TEnum>(string value)
where TEnum : struct, Enum
{
Expand Down Expand Up @@ -411,6 +437,8 @@

public string ReleaseTag { get; set; } = "latest";

public string WorkflowProfileId { get; set; } = string.Empty;

public string GitHubCredentialMode { get; set; } = CredentialInheritanceMode.InheritDefault.ToString();

public string OpenAiCredentialMode { get; set; } = CredentialInheritanceMode.InheritDefault.ToString();
Expand All @@ -428,6 +456,7 @@
InstanceBaseUrl = "http://localhost:8080/";
Port = 8080;
ReleaseTag = "latest";
WorkflowProfileId = string.Empty;
GitHubCredentialMode = CredentialInheritanceMode.InheritDefault.ToString();
OpenAiCredentialMode = CredentialInheritanceMode.InheritDefault.ToString();
}
Expand Down
Loading
Loading