diff --git a/Controllers/WorkflowFilesController.cs b/Controllers/WorkflowFilesController.cs index 45498e5..7f48826 100644 --- a/Controllers/WorkflowFilesController.cs +++ b/Controllers/WorkflowFilesController.cs @@ -71,7 +71,7 @@ public class WorkflowFilesController : ControllerBase if (string.IsNullOrWhiteSpace(login)) return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." }); - var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(login); + var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(TenantId, login); var fullName = ForgejoWorkflowRepoService.RepoFullNameForLogin(login); var exists = Directory.Exists(Path.Combine(cloneDir, ".git")); @@ -230,7 +230,7 @@ public class WorkflowFilesController : ControllerBase if (string.IsNullOrWhiteSpace(login)) return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." }); - var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(login); + var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(TenantId, login); if (!Directory.Exists(Path.Combine(cloneDir, ".git"))) return Ok(new { branch = "", changed = Array.Empty(), untracked = Array.Empty() }); @@ -276,7 +276,7 @@ public class WorkflowFilesController : ControllerBase : request!.Message; var (success, output) = await _sourceFactory.Forgejo.CommitAndPushAsync( - login, message, HttpContext.RequestAborted); + TenantId, login, message, HttpContext.RequestAborted); _logger.LogInformation("Commit/push for login {Login}: {Success} ({Output})", login, success, output?.Trim() ?? ""); @@ -295,7 +295,7 @@ public class WorkflowFilesController : ControllerBase if (string.IsNullOrWhiteSpace(login)) return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." }); - var ok = await _sourceFactory.Forgejo.PullAsync(login, HttpContext.RequestAborted); + var ok = await _sourceFactory.Forgejo.PullAsync(TenantId, login, HttpContext.RequestAborted); return Ok(new { success = ok }); } diff --git a/Services/ForgejoWorkflowRepoService.cs b/Services/ForgejoWorkflowRepoService.cs index 2e1690e..7a5205d 100644 --- a/Services/ForgejoWorkflowRepoService.cs +++ b/Services/ForgejoWorkflowRepoService.cs @@ -32,9 +32,15 @@ public sealed class ForgejoWorkflowRepoService _forgejoBase = (config["Forgejo:BaseUrl"] ?? "https://forgejo.wiz4chat.com").TrimEnd('/'); _forgejoToken = config["Forgejo:AccessToken"]?.Trim(); _forgejoAdminToken = config["Forgejo:AdminToken"]?.Trim(); - _forgejoOwner = config["Forgejo:WorkflowRepoOwner"] - ?? config["Forgejo:Owner"] - ?? string.Empty; + // Prefer WorkflowRepoOwner, but treat an explicit empty string as + // "not configured" and fall back to Forgejo:Owner. A plain `??` is not + // enough: appsettings ships `"WorkflowRepoOwner": ""`, which is non-null + // and would win over Forgejo:Owner, silently disabling Forgejo-backed + // workflow mode (IsConfigured reads owner as empty). + var workflowRepoOwner = config["Forgejo:WorkflowRepoOwner"]?.Trim(); + _forgejoOwner = !string.IsNullOrWhiteSpace(workflowRepoOwner) + ? workflowRepoOwner + : config["Forgejo:Owner"]?.Trim() ?? string.Empty; _copiesRoot = Path.GetFullPath( config["WorkflowSource:CopiesRoot"] ?? throw new InvalidOperationException( @@ -45,6 +51,17 @@ public sealed class ForgejoWorkflowRepoService /// The Forgejo base URL (for building repo links). public string ForgejoBaseUrl => _forgejoBase; + /// + /// The token used for git transport (clone/pull/push) against the per-user + /// private workflow repos. The repos are created and administered with the + /// ADMIN token, so that is the credential that actually works against them; + /// the plain AccessToken has no read/write grant on a user's private repo and + /// Forgejo reports it as "Repository not found". Fall back to the access + /// token when no admin token is configured. + /// + private string GitAuthToken => + !string.IsNullOrWhiteSpace(_forgejoAdminToken) ? _forgejoAdminToken : _forgejoToken; + /// True when Forgejo admin provisioning is configured. public bool IsConfigured => !string.IsNullOrWhiteSpace(_forgejoAdminToken) && @@ -65,6 +82,20 @@ public sealed class ForgejoWorkflowRepoService public static string RepoFullNameForLogin(string login) => $"{login.ToLowerInvariant()}/workflows-{Sanitize(login.ToLowerInvariant())}"; + /// + /// The source-copies owner-repo slug for a user's workflow repo, matching the + /// webapi SourceCodeCopyService layout ({owner}_{repo}, e.g. + /// test_workflows-test). The workflow repo is {login}/workflows-{login}, + /// so the slug is {login}_workflows-{login}. Sharing this exact directory + /// with the webapi source-code explorer is what keeps workflow edits visible in + /// the source-code view without a manual fetch/pull. + /// + public static string OwnerRepoSlug(string login) + { + var owner = login.ToLowerInvariant(); + return $"{Sanitize(owner)}_workflows-{Sanitize(owner)}"; + } + /// /// Ensures the user's Forgejo repo exists. Creates it via the admin API /// if missing (idempotent). Returns the repo full name (owner/repo). @@ -132,9 +163,9 @@ public sealed class ForgejoWorkflowRepoService /// doesn't exist on Forgejo yet, it is created. If the clone directory is /// missing, the repo is cloned. Returns the absolute path to the local clone. /// - public async Task EnsureCloneAsync(string login, CancellationToken ct = default) + public async Task EnsureCloneAsync(string tenantId, string login, CancellationToken ct = default) { - var tenantDir = TenantCloneDir(login); + var tenantDir = TenantCloneDir(tenantId, login); if (Directory.Exists(Path.Combine(tenantDir, ".git"))) { @@ -158,10 +189,16 @@ public sealed class ForgejoWorkflowRepoService CreateNoWindow = true, }; psi.ArgumentList.Add("clone"); - if (!string.IsNullOrWhiteSpace(_forgejoToken)) + // The per-user workflow repos are created and managed via the Forgejo + // ADMIN token, and each is private (owned by the user's own account). + // The plain AccessToken has no grant on those private repos (Forgejo + // returns 404), so cloning with it makes git fail with "Repository not + // found". Use the admin token (fallback to the access token) so the + // clone — and the later pull/push — succeed against the private repo. + if (!string.IsNullOrWhiteSpace(GitAuthToken)) { psi.ArgumentList.Add("-c"); - psi.ArgumentList.Add($"http.extraHeader=Authorization: token {_forgejoToken}"); + psi.ArgumentList.Add($"http.extraHeader=Authorization: token {GitAuthToken}"); } psi.ArgumentList.Add("--"); psi.ArgumentList.Add(cloneUrl); @@ -200,16 +237,16 @@ public sealed class ForgejoWorkflowRepoService /// /// Pulls the latest changes from the remote. Returns true on success. /// - public async Task PullAsync(string login, CancellationToken ct = default) + public async Task PullAsync(string tenantId, string login, CancellationToken ct = default) { - var tenantDir = TenantCloneDir(login); + var tenantDir = TenantCloneDir(tenantId, login); if (!Directory.Exists(Path.Combine(tenantDir, ".git"))) { _logger.LogWarning("No local clone for login {Login}, cannot pull", login); return false; } - var token = !string.IsNullOrWhiteSpace(_forgejoToken) ? _forgejoToken : _forgejoAdminToken; + var token = GitAuthToken; var output = !string.IsNullOrWhiteSpace(token) ? await RunGitAsync(tenantDir, ct, "-c", $"http.extraHeader=Authorization: token {token}", "pull", "--ff-only") : await RunGitAsync(tenantDir, ct, "pull", "--ff-only"); @@ -234,9 +271,9 @@ public sealed class ForgejoWorkflowRepoService /// edits via the source code API to persist changes to Forgejo. /// public async Task<(bool Success, string Output)> CommitAndPushAsync( - string tenantId, string message, CancellationToken ct = default) + string tenantId, string login, string message, CancellationToken ct = default) { - var tenantDir = TenantCloneDir(tenantId); + var tenantDir = TenantCloneDir(tenantId, login); if (!Directory.Exists(Path.Combine(tenantDir, ".git"))) return (false, "No local clone"); @@ -245,7 +282,7 @@ public sealed class ForgejoWorkflowRepoService if (commitOutput == null || commitOutput.Contains("nothing to commit", StringComparison.OrdinalIgnoreCase)) return (true, "Nothing to commit"); - var token = !string.IsNullOrWhiteSpace(_forgejoToken) ? _forgejoToken : _forgejoAdminToken; + var token = GitAuthToken; var pushOutput = !string.IsNullOrWhiteSpace(token) ? await RunGitAsync(tenantDir, ct, "-c", $"http.extraHeader=Authorization: token {token}", "push") : await RunGitAsync(tenantDir, ct, "push"); @@ -257,9 +294,17 @@ public sealed class ForgejoWorkflowRepoService return (ok, ok ? pushOutput?.Trim() ?? "Pushed" : pushOutput?.Trim() ?? "Push failed"); } - /// The absolute path to a tenant's local clone directory. - public string TenantCloneDir(string tenantId) - => Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId))); + /// + /// The absolute path to a tenant's local clone directory — shared with the + /// webapi source-code explorer. Layout: + /// {CopiesRoot}/{tenantId}/{ownerRepo} where ownerRepo is + /// (e.g. source-copies/10/test_workflows-test). + /// Both the w4c-workflows-api engine and the webapi SourceCodeController operate + /// on this one working copy, so workflow edits and the source-code view stay + /// consistent without a manual git pull. + /// + public string TenantCloneDir(string tenantId, string login) + => Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId), OwnerRepoSlug(login))); private async Task RunGitAsync(string workDir, CancellationToken ct, params string[] args) { diff --git a/Services/PerTenantWorkflowSource.cs b/Services/PerTenantWorkflowSource.cs index 195f864..97f62a7 100644 --- a/Services/PerTenantWorkflowSource.cs +++ b/Services/PerTenantWorkflowSource.cs @@ -97,7 +97,7 @@ public sealed class WorkflowSourceFactory public string ResolveTenantSourceDir(string tenantId, string? forgejoLogin = null) { if (_forgejo != null && !string.IsNullOrWhiteSpace(forgejoLogin)) - return _forgejo.TenantCloneDir(forgejoLogin); + return _forgejo.TenantCloneDir(tenantId, forgejoLogin); return TenantSourceDir(tenantId); } @@ -127,10 +127,10 @@ public sealed class WorkflowSourceFactory } // Ensure the Forgejo repo exists and is cloned locally. - var cloneDir = await _forgejo.EnsureCloneAsync(login, ct); + var cloneDir = await _forgejo.EnsureCloneAsync(tenantId, login, ct); // Pull latest changes before reading. - await _forgejo.PullAsync(login, ct); + await _forgejo.PullAsync(tenantId, login, ct); var logger = _loggers.CreateLogger(); return new PerTenantWorkflowSource(tenantId, cloneDir, logger); diff --git a/Services/Runs/TaskDispatcher.cs b/Services/Runs/TaskDispatcher.cs index c880845..127f5c7 100644 --- a/Services/Runs/TaskDispatcher.cs +++ b/Services/Runs/TaskDispatcher.cs @@ -62,7 +62,7 @@ public class TaskDispatcher _db.TaskRuns.Add(taskRun); await _db.SaveChangesAsync(ct); - var workingDir = ResolveWorkingDir(run.TenantId, workflowPath); + var workingDir = await ResolveWorkingDirAsync(run.TenantId, workflowPath, ct); var fields = TaskRunMessage.ToFields(run, task, input, workingDir, attempt); await _jobs.EnqueueAsync(run.TenantId, fields, ct); @@ -82,7 +82,7 @@ public class TaskDispatcher public async Task DeadLetterTaskAsync( WorkflowRun run, WorkflowTask task, TaskRun taskRun, CancellationToken ct) { - var workingDir = ResolveWorkingDir(run.TenantId, run.Workflow.Path); + var workingDir = await ResolveWorkingDirAsync(run.TenantId, run.Workflow.Path, ct); var fields = TaskRunMessage.ToFields( run, task, taskRun.InputJson, workingDir, taskRun.Attempt); @@ -124,6 +124,21 @@ public class TaskDispatcher return ResolveExistingDir(workerRoot, dir); } + /// + /// Async variant used by the live dispatch path. In Forgejo-backed mode the + /// working root is the tenant's shared local clone, whose path depends on the + /// user's Forgejo login — resolved here (async, DB-backed) so the worker runs + /// the exact files being edited in the source-code explorer. + /// + public async Task ResolveWorkingDirAsync(string tenantId, string workflowPath, CancellationToken ct = default) + { + var workerRoot = await ResolveWorkerRootForTenantAsync(tenantId, ct); + var dir = Path.GetDirectoryName(workflowPath) ?? string.Empty; + if (string.IsNullOrWhiteSpace(dir)) + return workerRoot; + return ResolveExistingDir(workerRoot, dir); + } + /// /// Legacy overload for backward compatibility. Resolves against the default worker root. /// @@ -168,10 +183,20 @@ public class TaskDispatcher private string ResolveWorkerRootForTenant(string tenantId) { - // In Forgejo-backed mode, resolve from the tenant's local clone. + // Filesystem-only mode: use the global worker root. + return _defaultWorkerRoot; + } + + private async Task ResolveWorkerRootForTenantAsync(string tenantId, CancellationToken ct) + { + // In Forgejo-backed mode, resolve from the tenant's shared local clone, + // whose path depends on the user's Forgejo login (resolved via the factory). if (_sourceFactory?.IsForgejoBacked == true && _sourceFactory.Forgejo != null) { - return _sourceFactory.Forgejo.TenantCloneDir(tenantId); + var login = await _sourceFactory.ResolveForgejoLoginAsync(tenantId, ct); + if (!string.IsNullOrEmpty(login)) + return _sourceFactory.Forgejo.TenantCloneDir(tenantId, login); + return _defaultWorkerRoot; } // Filesystem-only mode: use the global worker root. diff --git a/Services/WorkflowSyncService.cs b/Services/WorkflowSyncService.cs index 7c0d24e..6c71591 100644 --- a/Services/WorkflowSyncService.cs +++ b/Services/WorkflowSyncService.cs @@ -53,7 +53,9 @@ public class WorkflowSyncService if (_sourceFactory?.IsForgejoBacked == true && _sourceFactory.Forgejo != null) { _logger.LogDebug("Pulling latest workflow files from Forgejo for tenant {TenantId}", tenantId); - await _sourceFactory.Forgejo.PullAsync(tenantId, ct); + var login = await _sourceFactory.ResolveForgejoLoginAsync(tenantId, ct); + if (!string.IsNullOrEmpty(login)) + await _sourceFactory.Forgejo.PullAsync(tenantId, login, ct); } var state = _source.GetState(); diff --git a/appsettings.json b/appsettings.json index 888ae5d..6ecf270 100644 --- a/appsettings.json +++ b/appsettings.json @@ -68,7 +68,7 @@ "RepoRoot": "" }, "WorkflowSource": { - "CopiesRoot": ".data/workflow-tenants", + "CopiesRoot": "../source-copies", "SharedDir": "workflows" } }