This commit is contained in:
Vitali sharp8n 2026-09-03 10:59:54 +03:00
parent 1d9987c3ca
commit 3b50333353
6 changed files with 101 additions and 29 deletions

View file

@ -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<object>(), untracked = Array.Empty<object>() });
@ -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 });
}

View file

@ -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
/// <summary>The Forgejo base URL (for building repo links).</summary>
public string ForgejoBaseUrl => _forgejoBase;
/// <summary>
/// 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.
/// </summary>
private string GitAuthToken =>
!string.IsNullOrWhiteSpace(_forgejoAdminToken) ? _forgejoAdminToken : _forgejoToken;
/// <summary>True when Forgejo admin provisioning is configured.</summary>
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())}";
/// <summary>
/// The source-copies owner-repo slug for a user's workflow repo, matching the
/// webapi SourceCodeCopyService layout (<c>{owner}_{repo}</c>, e.g.
/// <c>test_workflows-test</c>). The workflow repo is <c>{login}/workflows-{login}</c>,
/// so the slug is <c>{login}_workflows-{login}</c>. 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.
/// </summary>
public static string OwnerRepoSlug(string login)
{
var owner = login.ToLowerInvariant();
return $"{Sanitize(owner)}_workflows-{Sanitize(owner)}";
}
/// <summary>
/// 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.
/// </summary>
public async Task<string> EnsureCloneAsync(string login, CancellationToken ct = default)
public async Task<string> 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
/// <summary>
/// Pulls the latest changes from the remote. Returns true on success.
/// </summary>
public async Task<bool> PullAsync(string login, CancellationToken ct = default)
public async Task<bool> 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.
/// </summary>
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");
}
/// <summary>The absolute path to a tenant's local clone directory.</summary>
public string TenantCloneDir(string tenantId)
=> Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId)));
/// <summary>
/// The absolute path to a tenant's local clone directory — shared with the
/// webapi source-code explorer. Layout:
/// <c>{CopiesRoot}/{tenantId}/{ownerRepo}</c> where <c>ownerRepo</c> is
/// <see cref="OwnerRepoSlug"/> (e.g. <c>source-copies/10/test_workflows-test</c>).
/// 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.
/// </summary>
public string TenantCloneDir(string tenantId, string login)
=> Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId), OwnerRepoSlug(login)));
private async Task<string?> RunGitAsync(string workDir, CancellationToken ct, params string[] args)
{

View file

@ -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<PerTenantWorkflowSource>();
return new PerTenantWorkflowSource(tenantId, cloneDir, logger);

View file

@ -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);
}
/// <summary>
/// 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.
/// </summary>
public async Task<string> 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);
}
/// <summary>
/// Legacy overload for backward compatibility. Resolves against the default worker root.
/// </summary>
@ -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<string> 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.

View file

@ -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();

View file

@ -68,7 +68,7 @@
"RepoRoot": ""
},
"WorkflowSource": {
"CopiesRoot": ".data/workflow-tenants",
"CopiesRoot": "../source-copies",
"SharedDir": "workflows"
}
}