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
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ All notable changes to this project will be documented in this file.
- Added health polling alert evaluation for offline Symphony instances.
- Added a documentation validation workflow for required files, trailing whitespace, local links, and fenced code blocks.
- Added baseline placeholder documents for the planned documentation set.
- Added a SYMPHONY-compatible `WORKFLOW.md` generator for Docker and local execution modes with profile prompt extraction and defaults.
2 changes: 1 addition & 1 deletion docs/writeup.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ we can democratise professional software development; give vibe coders somewhere
from real production ones. We're giving the world the ability to deliver commercial quality code at a fraction of the price and time of historical development.

Small entrepreneurs now really have a chance to get out a working, reliable, secure and sclaable software solution without breaking the bank (and leaving money
to spend on marketing).
to spend on marketing).
184 changes: 184 additions & 0 deletions src/Conductor.Core/Application/Workflows/WorkflowGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
using System.IO;
using System.Text;
using Conductor.Core.Common;
using Conductor.Core.Domain;
using Conductor.Core.Domain.Repositories;

namespace Conductor.Core.Application.Workflows;

public interface IWorkflowGenerator
{
string Generate(WorkflowGenerationRequest request);
}

public sealed class WorkflowGenerator : IWorkflowGenerator
{
private const int DefaultPort = 8080;
private const string DockerWorkspaceRoot = "/var/lib/symphony/workspaces";
private const string DockerSharedClonePath = "/var/lib/symphony/workspaces/repo";
private const string DockerWorktreesRoot = "/var/lib/symphony/workspaces/worktrees";
private const string DefaultBaseBranch = "main";
private const string DefaultPrompt = """
You are working on a repository workflow.

Keep execution disciplined.
Use repository signals and prompt instructions to prioritize safe, incremental progress.
""";

public string Generate(WorkflowGenerationRequest request)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(request.RepositoryFullName);

int port = request.Port ?? DefaultPort;
if (port is < 1 or > 65535)
{
throw new ArgumentOutOfRangeException(nameof(request.Port), "Port must be between 1 and 65535.");
}

string baseBranch = string.IsNullOrWhiteSpace(request.BaseBranch)
? DefaultBaseBranch
: request.BaseBranch.Trim();

string remoteUrl = ResolveRemoteUrl(request);
(string workspaceRoot, string workspaceSharedClonePath, string workspaceWorktreesRoot) =
ResolveWorkspacePaths(request);

string prompt = ResolvePrompt(request.ProfileSource);
StringBuilder workflow = new();

workflow.AppendLine("---");
workflow.AppendLine("tracker:");
workflow.AppendLine($" kind: github");
workflow.AppendLine($" owner: {Quote(request.RepositoryFullName.Owner)}");
workflow.AppendLine($" repo: {Quote(request.RepositoryFullName.Name)}");
workflow.AppendLine(" api_key: $GITHUB_TOKEN");
workflow.AppendLine(" includePullRequests: true");
workflow.AppendLine(" activeStates:");
workflow.AppendLine(" - Open");
workflow.AppendLine(" - In Progress");
workflow.AppendLine(" terminalStates:");
workflow.AppendLine(" - Closed");
workflow.AppendLine("polling:");
workflow.AppendLine(" intervalMs: 600000");
workflow.AppendLine("agent:");
workflow.AppendLine(" maxConcurrentAgents: 5");
workflow.AppendLine(" maxTurns: 20");
workflow.AppendLine(" maxRetryBackoffMs: 300000");
workflow.AppendLine(" maxConcurrentAgentsByState:");
workflow.AppendLine(" Open: 2");
workflow.AppendLine(" In Progress: 3");
workflow.AppendLine("codex:");
workflow.AppendLine(" command: codex app-server");
workflow.AppendLine(" api_key: $OPENAI_API_KEY");
workflow.AppendLine(" turnTimeoutMs: 3600000");
workflow.AppendLine(" approvalPolicy: never");
workflow.AppendLine(" threadSandbox: danger-full-access");
workflow.AppendLine(" turnSandboxPolicy: danger-full-access");
workflow.AppendLine(" readTimeoutMs: 5000");
workflow.AppendLine(" stallTimeoutMs: 300000");
workflow.AppendLine($" openaiApiKey: {Quote("$OPENAI_API_KEY")}");
workflow.AppendLine("server:");
workflow.AppendLine($" port: {port}");
workflow.AppendLine("workspace:");
workflow.AppendLine($" root: {Quote(workspaceRoot)}");
workflow.AppendLine($" sharedClonePath: {Quote(workspaceSharedClonePath)}");
workflow.AppendLine($" worktreesRoot: {Quote(workspaceWorktreesRoot)}");
workflow.AppendLine($" baseBranch: {Quote(baseBranch)}");
workflow.AppendLine($" remoteUrl: {Quote(remoteUrl)}");
workflow.AppendLine("hooks:");
workflow.AppendLine(" hasAfterCreate: false");
workflow.AppendLine(" hasBeforeRun: true");
workflow.AppendLine(" hasAfterRun: true");
workflow.AppendLine(" hasBeforeRemove: false");
workflow.AppendLine(" beforeRemoveSupported: true");
workflow.AppendLine(" timeoutMs: 60000");
workflow.AppendLine("---");
workflow.AppendLine();
workflow.AppendLine(prompt);

return workflow.ToString();
}

private static string ResolvePrompt(string? profileSource)
{
if (string.IsNullOrWhiteSpace(profileSource))
{
return DefaultPrompt;
}

ReadOnlySpan<char> source = profileSource.AsSpan().Trim();
if (!source.StartsWith("---".AsSpan(), StringComparison.Ordinal))
{
return source.ToString();
}

string normalized = NormalizeLineEndings(source.ToString());
string[] lines = normalized.Split('\n');
int markerIndex = -1;

for (int i = 1; i < lines.Length; i++)
{
if (lines[i].Trim() == "---")
{
markerIndex = i;
break;
}
}

if (markerIndex < 0 || markerIndex == lines.Length - 1)
{
return DefaultPrompt;
}

string prompt = string.Join('\n', lines[(markerIndex + 1)..]).Trim();
return string.IsNullOrWhiteSpace(prompt) ? DefaultPrompt : prompt;
}

private static string ResolveRemoteUrl(WorkflowGenerationRequest request)
{
if (!string.IsNullOrWhiteSpace(request.RemoteUrl))
{
return request.RemoteUrl!.Trim();
}

return $"https://github.com/{request.RepositoryFullName.Value}.git";
}

private static (string root, string sharedClonePath, string worktreesRoot) ResolveWorkspacePaths(
WorkflowGenerationRequest request)
{
if (request.ExecutionMode == ExecutionMode.Docker)
{
return (DockerWorkspaceRoot, DockerSharedClonePath, DockerWorktreesRoot);
}

string root = string.IsNullOrWhiteSpace(request.WorkspaceRoot)
? Path.Combine(
"instances",
request.RepositoryFullName.Owner,
request.RepositoryFullName.Name,
"workspaces")
: request.WorkspaceRoot.Trim();

string sharedClonePath = Path.Combine(root, "repo");
string worktreesRoot = Path.Combine(root, "worktrees");

return (root, sharedClonePath, worktreesRoot);
}

private static string NormalizeLineEndings(string value) =>
value.Replace("\r\n", "\n", StringComparison.OrdinalIgnoreCase);

private static string Quote(string? value) =>
$"\"{(value ?? string.Empty).Replace("\"", "\\\"", StringComparison.Ordinal)}\"";
}

public sealed record WorkflowGenerationRequest(
GitHubRepositoryFullName RepositoryFullName,
ExecutionMode ExecutionMode,
int? Port = null,
string? ProfileSource = null,
string? WorkspaceRoot = null,
string? BaseBranch = null,
string? RemoteUrl = null);
104 changes: 104 additions & 0 deletions tests/Conductor.Core.Tests/WorkflowGeneratorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using Conductor.Core.Application.Workflows;
using Conductor.Core.Domain;
using Conductor.Core.Domain.Repositories;

namespace Conductor.Core.Tests;

public sealed class WorkflowGeneratorTests
{
[Fact]
public void Generate_Docker_Workflow_Includes_Docker_Workspace_Layout_And_Required_Fields()
{
WorkflowGenerator generator = new();
WorkflowGenerationRequest request = new(
new GitHubRepositoryFullName("ReleasedGroup", "TheConductor"),
ExecutionMode.Docker,
Port: 18081,
ProfileSource: "Review open GitHub issues with care.",
BaseBranch: "main",
RemoteUrl: "https://github.com/ReleasedGroup/TheConductor.git");

string workflow = generator.Generate(request);

Assert.Contains("tracker:", workflow, StringComparison.Ordinal);
Assert.Contains("owner: \"ReleasedGroup\"", workflow, StringComparison.Ordinal);
Assert.Contains("repo: \"TheConductor\"", workflow, StringComparison.Ordinal);
Assert.Contains("api_key: $GITHUB_TOKEN", workflow, StringComparison.Ordinal);
Assert.Contains("activeStates:", workflow, StringComparison.Ordinal);
Assert.Contains("terminalStates:", workflow, StringComparison.Ordinal);
Assert.Contains("polling:", workflow, StringComparison.Ordinal);
Assert.Contains("agent:", workflow, StringComparison.Ordinal);
Assert.Contains("codex:", workflow, StringComparison.Ordinal);
Assert.Contains("server:", workflow, StringComparison.Ordinal);
Assert.Contains("port: 18081", workflow, StringComparison.Ordinal);
Assert.Contains("workspace:", workflow, StringComparison.Ordinal);
Assert.Contains("root: \"/var/lib/symphony/workspaces\"", workflow, StringComparison.Ordinal);
Assert.Contains("sharedClonePath: \"/var/lib/symphony/workspaces/repo\"", workflow, StringComparison.Ordinal);
Assert.Contains("worktreesRoot: \"/var/lib/symphony/workspaces/worktrees\"", workflow, StringComparison.Ordinal);
Assert.Contains("hooks:", workflow, StringComparison.Ordinal);
Assert.Contains("Review open GitHub issues with care.", workflow, StringComparison.Ordinal);
}

[Fact]
public void Generate_Local_Workflow_Uses_Provided_Host_Workspace_Paths()
{
WorkflowGenerator generator = new();
string workspaceRoot = Path.Combine("build", "workspaces");
WorkflowGenerationRequest request = new(
new GitHubRepositoryFullName("ReleasedGroup", "TheConductor"),
ExecutionMode.LocalProcess,
Port: 8080,
WorkspaceRoot: workspaceRoot,
ProfileSource: "Implement the requested fix.");

string workflow = generator.Generate(request);

string expectedSharedClonePath = Path.Combine(workspaceRoot, "repo");
string expectedWorktreesRoot = Path.Combine(workspaceRoot, "worktrees");

Assert.Contains($"root: \"{workspaceRoot}\"", workflow, StringComparison.Ordinal);
Assert.Contains($"sharedClonePath: \"{expectedSharedClonePath}\"", workflow, StringComparison.Ordinal);
Assert.Contains($"worktreesRoot: \"{expectedWorktreesRoot}\"", workflow, StringComparison.Ordinal);
Assert.DoesNotContain("/var/lib/symphony/workspaces", workflow, StringComparison.Ordinal);
Assert.Contains("Implement the requested fix.", workflow, StringComparison.Ordinal);
}

[Fact]
public void Generate_Strips_Profile_Front_Matter_And_Uses_Default_Prompt_When_Missing()
{
WorkflowGenerator generator = new();
string profileSource = """
---
tracker:
owner: ignored
---
Use the provided issue instructions below.
""";
WorkflowGenerationRequest request = new(
new GitHubRepositoryFullName("ReleasedGroup", "TheConductor"),
ExecutionMode.LocalProcess,
ProfileSource: profileSource,
Port: 8080);

string workflow = generator.Generate(request);

Assert.Contains("Use the provided issue instructions below.", workflow, StringComparison.Ordinal);
Assert.DoesNotContain("owner: \"ignored\"", workflow, StringComparison.Ordinal);
}

[Fact]
public void Generate_Uses_Default_Prompt_When_Profile_Is_Blank()
{
WorkflowGenerator generator = new();
WorkflowGenerationRequest request = new(
new GitHubRepositoryFullName("ReleasedGroup", "TheConductor"),
ExecutionMode.Docker,
Port: null,
ProfileSource: " ");

string workflow = generator.Generate(request);

Assert.Contains("You are working on a repository workflow.", workflow, StringComparison.Ordinal);
Assert.Contains("port: 8080", workflow, StringComparison.Ordinal);
}
}
Loading