diff --git a/docs/changelog.md b/docs/changelog.md index abbc6b8..01004c2 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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. diff --git a/docs/writeup.md b/docs/writeup.md index e952c76..274e315 100644 --- a/docs/writeup.md +++ b/docs/writeup.md @@ -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). \ No newline at end of file +to spend on marketing). diff --git a/src/Conductor.Core/Application/Workflows/WorkflowGenerator.cs b/src/Conductor.Core/Application/Workflows/WorkflowGenerator.cs new file mode 100644 index 0000000..885c09e --- /dev/null +++ b/src/Conductor.Core/Application/Workflows/WorkflowGenerator.cs @@ -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 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); diff --git a/tests/Conductor.Core.Tests/WorkflowGeneratorTests.cs b/tests/Conductor.Core.Tests/WorkflowGeneratorTests.cs new file mode 100644 index 0000000..961d8ef --- /dev/null +++ b/tests/Conductor.Core.Tests/WorkflowGeneratorTests.cs @@ -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); + } +}