diff --git a/Services/ForgejoWorkflowRepoService.cs b/Services/ForgejoWorkflowRepoService.cs index 85df995..9fec2af 100644 --- a/Services/ForgejoWorkflowRepoService.cs +++ b/Services/ForgejoWorkflowRepoService.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using Microsoft.AspNetCore.Hosting; namespace w4c_workflows.Services; @@ -26,7 +27,7 @@ public sealed class ForgejoWorkflowRepoService private readonly string _copiesRoot; private readonly ILogger _logger; - public ForgejoWorkflowRepoService(IConfiguration config, ILogger logger) + public ForgejoWorkflowRepoService(IConfiguration config, IWebHostEnvironment env, ILogger logger) { _logger = logger; _forgejoBase = (config["Forgejo:BaseUrl"] ?? "https://forgejo.wiz4chat.com").TrimEnd('/'); @@ -41,11 +42,15 @@ public sealed class ForgejoWorkflowRepoService _forgejoOwner = !string.IsNullOrWhiteSpace(workflowRepoOwner) ? workflowRepoOwner : config["Forgejo:Owner"]?.Trim() ?? string.Empty; - _copiesRoot = Path.GetFullPath( + // Resolve CopiesRoot against the content root (not the process CWD) so a + // relative value like "../source-copies" lands on the same wizard root + // regardless of the working directory the service was launched from. + _copiesRoot = WorkflowSourceFactory.ResolveCopiesRoot( config["WorkflowSource:CopiesRoot"] ?? throw new InvalidOperationException( "WorkflowSource:CopiesRoot is not configured. " + - "Set it to a writable directory path (e.g. \"/data/workflow-tenants\").")); + "Set it to a writable directory path (e.g. \"/data/workflow-tenants\")."), + env); // The workflow repo name (basename). Default "workflows" → {login}/workflows, // so a tenant's workflows live in a repo whose name contains "workflow". // A per-tenant selection overrides this at runtime. diff --git a/Services/PerTenantWorkflowSource.cs b/Services/PerTenantWorkflowSource.cs index 886d8f0..a93ea49 100644 --- a/Services/PerTenantWorkflowSource.cs +++ b/Services/PerTenantWorkflowSource.cs @@ -43,16 +43,17 @@ public sealed class WorkflowSourceFactory _loggers = loggers; _ds = ds; - _copiesRoot = Path.GetFullPath( + _copiesRoot = ResolveCopiesRoot( config["WorkflowSource:CopiesRoot"] ?? throw new InvalidOperationException( "WorkflowSource:CopiesRoot is not configured. " + "Per-tenant filesystem isolation is required — set it to a writable " + - "directory path (e.g. \"/data/workflow-tenants\" or \".data/workflow-tenants\").")); + "directory path (e.g. \"/data/workflow-tenants\" or \".data/workflow-tenants\")."), + env); // Create the Forgejo service when admin provisioning is configured. var forgejoLogger = loggers.CreateLogger(); - var forgejoSvc = new ForgejoWorkflowRepoService(config, forgejoLogger); + var forgejoSvc = new ForgejoWorkflowRepoService(config, env, forgejoLogger); _forgejo = forgejoSvc.IsConfigured ? forgejoSvc : null; if (_forgejo != null) @@ -162,6 +163,24 @@ public sealed class WorkflowSourceFactory } } + /// + /// Resolves WorkflowSource:CopiesRoot to an absolute path independent of + /// the process CWD. A relative value (e.g. ../source-copies) is anchored + /// to the app's content root instead of , + /// so a worker started from a container/different working directory still lands on + /// the same wizard root as the dev run. An absolute value is normalized as-is. + /// + internal static string ResolveCopiesRoot(string raw, IWebHostEnvironment? env) + { + if (Path.IsPathRooted(raw)) + return Path.GetFullPath(raw); + + var basePath = env?.ContentRootPath; + if (string.IsNullOrWhiteSpace(basePath)) + basePath = Directory.GetCurrentDirectory(); + return Path.GetFullPath(raw, basePath); + } + private static string Sanitize(string id) { if (string.IsNullOrWhiteSpace(id)) diff --git a/Services/Runs/TaskDispatcher.cs b/Services/Runs/TaskDispatcher.cs index edcf5fd..1317449 100644 --- a/Services/Runs/TaskDispatcher.cs +++ b/Services/Runs/TaskDispatcher.cs @@ -20,6 +20,7 @@ public class TaskDispatcher private readonly string _defaultWorkerRoot; private readonly string _sharedDir; private readonly WorkflowSourceFactory? _sourceFactory; + private readonly WorkflowRepoStore? _repoStore; private readonly ILogger _logger; public TaskDispatcher( @@ -27,12 +28,14 @@ public class TaskDispatcher IJobQueue jobs, IConfiguration config, ILogger logger, - WorkflowSourceFactory? sourceFactory = null) + WorkflowSourceFactory? sourceFactory = null, + WorkflowRepoStore? repoStore = null) { _db = db; _jobs = jobs; _logger = logger; _sourceFactory = sourceFactory; + _repoStore = repoStore; _defaultWorkerRoot = ResolveWorkerRoot(config); _sharedDir = config["WorkflowSource:SharedDir"] ?? "workflows"; } @@ -204,10 +207,83 @@ public class TaskDispatcher if (_sourceFactory == null) return _defaultWorkerRoot; - var login = _sourceFactory.IsForgejoBacked - ? await _sourceFactory.ResolveForgejoLoginAsync(tenantId, ct) - : null; - return _sourceFactory.ResolveTenantSourceDir(tenantId, login); + // Use the tenant's SELECTED workflow repo (the clone the file-writer and + // the source explorer land on), falling back to the configured default + // (repoName=null) when the store is unavailable. Before this, the worker + // always resolved the DEFAULT repo, so a tenant that had switched to a + // different repo got a working directory that did not contain its entry + // file (surfacing as "entry file not found: Main.cs" in the C# executor). + var repoName = await ResolveRepoNameAsync(tenantId, ct); + + if (_sourceFactory.IsForgejoBacked) + { + var login = await _sourceFactory.ResolveForgejoLoginAsync(tenantId, ct); + if (!string.IsNullOrWhiteSpace(login)) + { + // Materialize the tenant's repo clone if it is not already present on + // THIS instance before resolving the working directory. The writer may + // have cloned the repo on a different host/instance; without this the + // worker resolves a working directory whose code files do not exist + // ("entry file not found: Main.cs"). EnsureClone is a no-op once the + // clone exists, so repeated dispatches stay cheap. + await EnsureCloneAsync(tenantId, login, repoName, ct); + return _sourceFactory.ResolveTenantSourceDir(tenantId, login, repoName); + } + } + + return _sourceFactory.ResolveTenantSourceDir(tenantId, null, repoName); + } + + /// + /// Best-effort clone/pull of the tenant's workflow repo so the working directory + /// the worker will execute in actually contains the code files. Failures are + /// logged and swallowed: the resolved path is still returned, so a genuinely + /// missing repo surfaces its normal "entry file not found" at execution time + /// rather than silently dispatching to a directory we cannot reason about. + /// + private async Task EnsureCloneAsync(string tenantId, string login, string? repoName, CancellationToken ct) + { + var forgejo = _sourceFactory?.Forgejo; + if (forgejo == null) + return; + + try + { + await forgejo.EnsureCloneAsync(tenantId, login, repoName, ct); + // No per-dispatch PullAsync: a full git pull is too slow to run inside + // the control-plane dispatch loop on every tick. EnsureClone guarantees + // the working directory exists; freshness is handled by the compile/edit + // paths that already pull the repo on write/sync. + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Could not ensure workflow repo clone for tenant {TenantId} (login {Login}); dispatching with the resolved path as-is", + tenantId, login); + } + } + + /// + /// Resolves the tenant's currently-selected workflow repo name (as persisted in + /// ). Returns null when the store is not + /// configured or the lookup fails, letting the caller fall back to the factory's + /// configured default repo. + /// + private async Task ResolveRepoNameAsync(string tenantId, CancellationToken ct) + { + if (_repoStore == null) + return null; + + try + { + return await _repoStore.GetNameAsync(tenantId, ct); + } + catch (Exception ex) + { + _logger.LogDebug(ex, + "Could not resolve selected workflow repo for tenant {TenantId}; using default", tenantId); + return null; + } } private static string ResolveWorkerRoot(IConfiguration config)