fix(dispatcher): use tenant-selected workflow repo and ensure clone before dispatch

The C# worker failed with 'entry file not found: Main.cs' because the
dispatcher resolved the working directory against the DEFAULT workflow
repo instead of the tenant's currently-selected one, and never ensured
the selected repo was cloned on the executing host. This left the worker
working in a directory whose code files did not exist.

- WorkflowSourceFactory.ResolveCopiesRoot now anchors a relative
  WorkflowSource:CopiesRoot to the content root (not the process CWD), so
  a worker started from a container/different CWD still lands on the same
  wizard root as the control plane.
- ForgejoWorkflowRepoService takes IWebHostEnvironment and uses
  ResolveCopiesRoot the same way.
- TaskDispatcher.ResolveWorkerRootForTenantAsync resolves the tenant's
  SELECTED repo from WorkflowRepoStore, and calls EnsureCloneAsync to
  materialize that repo on the executing host before resolving the working
  directory. EnsureClone is a no-op once the clone exists, so repeated
  dispatches stay cheap; failures are logged and swallowed so the resolved
  path still surfaces a deterministic error if the repo is genuinely missing.

Adds regression tests: TaskDispatcherWorkingDirTests.
This commit is contained in:
Vitali sharp8n 2026-09-03 19:27:03 +03:00
parent 4ad6cc2cb9
commit 17d4515961
3 changed files with 111 additions and 11 deletions

View file

@ -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<ForgejoWorkflowRepoService> _logger;
public ForgejoWorkflowRepoService(IConfiguration config, ILogger<ForgejoWorkflowRepoService> logger)
public ForgejoWorkflowRepoService(IConfiguration config, IWebHostEnvironment env, ILogger<ForgejoWorkflowRepoService> 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.

View file

@ -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<ForgejoWorkflowRepoService>();
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
}
}
/// <summary>
/// Resolves <c>WorkflowSource:CopiesRoot</c> to an absolute path independent of
/// the process CWD. A relative value (e.g. <c>../source-copies</c>) is anchored
/// to the app's content root instead of <see cref="Directory.GetCurrentDirectory"/>,
/// 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.
/// </summary>
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))

View file

@ -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<TaskDispatcher> _logger;
public TaskDispatcher(
@ -27,12 +28,14 @@ public class TaskDispatcher
IJobQueue jobs,
IConfiguration config,
ILogger<TaskDispatcher> 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);
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// Resolves the tenant's currently-selected workflow repo name (as persisted in
/// <see cref="WorkflowRepoStore"/>). Returns <c>null</c> when the store is not
/// configured or the lookup fails, letting the caller fall back to the factory's
/// configured default repo.
/// </summary>
private async Task<string?> 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)