diff --git a/Controllers/WorkflowFilesController.cs b/Controllers/WorkflowFilesController.cs
index ce3c0f1..45498e5 100644
--- a/Controllers/WorkflowFilesController.cs
+++ b/Controllers/WorkflowFilesController.cs
@@ -8,10 +8,21 @@ namespace w4c_workflows.Controllers;
///
/// Workflow file management endpoints. Provides CRUD operations for the
/// workflow YAML definitions and their sibling code files (shell scripts,
-/// JS, Python, etc.) stored in the tenant's private Forgejo repo.
+/// JS, Python, etc.) that live in the tenant's per-tenant workflow repository.
///
-/// Requires the Forgejo-backed mode to be active (Forgejo:AdminToken
-/// configured). In filesystem-only mode these endpoints return 503.
+/// The repository is the single source of truth for a tenant's workflow content:
+/// the workflows module edits files here and POST /api/workflows/sync
+/// recompiles them (see ). Edits land in the
+/// SAME directory reads from, so the "edit in the
+/// workflows module" and "the engine runs it" paths are always coherent.
+///
+/// Works in both storage modes:
+/// - Forgejo-backed (Forgejo:AdminToken set): files live in a private
+/// per-user Forgejo repo {login}/workflows-{login} , cloned locally.
+/// /commit and /pull sync to/from the remote.
+/// - Filesystem-only (no AdminToken): files live under
+/// {CopiesRoot}/{tenantId}/ with no remote. /commit and
+/// /pull are no-ops.
///
[ApiController]
[Route("api/workflow-files")]
@@ -32,12 +43,22 @@ public class WorkflowFilesController : ControllerBase
?? throw new InvalidOperationException("TenantId not resolved by auth middleware");
///
- /// The user's Forgejo login, resolved from the tenant ID by the
- /// WorkflowSourceMiddleware. Used for repo path lookups since repos
- /// are per-user: {login}/workflows-{login} .
+ /// The user's Forgejo login when the middleware resolved it (Forgejo-backed
+ /// mode succeeded). Null when in filesystem-only mode OR when the Forgejo
+ /// login could not be resolved (the request then falls back to the filesystem
+ /// source, so file CRUD must target the filesystem dir to stay consistent).
///
- private string ForgejoLogin => (string?)HttpContext.Items["ForgejoLogin"]
- ?? throw new InvalidOperationException("ForgejoLogin not resolved by WorkflowSourceMiddleware");
+ private string? MaybeForgejoLogin =>
+ HttpContext.Items.TryGetValue("ForgejoLogin", out var v) ? v as string : null;
+
+ ///
+ /// The on-disk directory holding the tenant's workflow files. In Forgejo-backed
+ /// mode (login resolved) it is the per-user clone dir; otherwise it is the
+ /// per-tenant directory. Both are exactly the root
+ /// reads from, so file edits made through these endpoints are what get compiled
+ /// on sync.
+ ///
+ private string SourceDir => _sourceFactory.ResolveTenantSourceDir(TenantId, MaybeForgejoLogin);
/// Returns the Forgejo repo info for this tenant's workflow files.
[HttpGet("repo")]
@@ -46,8 +67,10 @@ public class WorkflowFilesController : ControllerBase
{
if (_sourceFactory.Forgejo == null)
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
+ var login = MaybeForgejoLogin;
+ if (string.IsNullOrWhiteSpace(login))
+ return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
- var login = ForgejoLogin;
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(login);
var fullName = ForgejoWorkflowRepoService.RepoFullNameForLogin(login);
var exists = Directory.Exists(Path.Combine(cloneDir, ".git"));
@@ -57,47 +80,50 @@ public class WorkflowFilesController : ControllerBase
repoFullName = fullName,
cloneDir,
cloned = exists,
- forgejoUrl = $"{_sourceFactory.Forgejo.GetType().GetProperty("ForgejoBase")?.GetValue(_sourceFactory.Forgejo) ?? "https://forgejo.wiz4chat.com"}/{fullName}",
+ forgejoUrl = $"{_sourceFactory.Forgejo.ForgejoBaseUrl}/{fullName}",
});
}
///
- /// Lists all files in the tenant's workflow repo (recursive, excluding .git).
- /// Returns relative paths and basic file info.
+ /// Lists all files under a directory (recursive, excluding .git), plus the
+ /// top-level subdirectories. Returns repo-relative paths.
///
[HttpGet]
[RequireScope("read")]
public IActionResult ListFiles([FromQuery] string? path = null)
{
- if (_sourceFactory.Forgejo == null)
- return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
-
- var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(ForgejoLogin);
- if (!Directory.Exists(cloneDir))
+ var sourceDir = SourceDir;
+ if (!Directory.Exists(sourceDir))
return Ok(new { files = Array.Empty() });
var dir = string.IsNullOrWhiteSpace(path)
- ? cloneDir
- : Path.Combine(cloneDir, path.Replace('/', Path.DirectorySeparatorChar));
+ ? sourceDir
+ : Path.Combine(sourceDir, path.Replace('/', Path.DirectorySeparatorChar));
- if (!Directory.Exists(dir))
+ var fullSourceDir = Path.GetFullPath(sourceDir);
+ var fullDir = Path.GetFullPath(dir);
+ if (!fullDir.StartsWith(fullSourceDir, StringComparison.Ordinal))
+ return BadRequest(new { error = "Invalid path" });
+
+ if (!Directory.Exists(fullDir))
return NotFound(new { error = $"Directory not found: {path}" });
var files = new List();
- foreach (var full in Directory.EnumerateFiles(dir))
+ foreach (var full in Directory.EnumerateFiles(fullDir, "*", SearchOption.AllDirectories))
{
- var name = Path.GetFileName(full);
- var rel = Path.GetRelativePath(cloneDir, full).Replace(Path.DirectorySeparatorChar, '/');
+ var rel = Path.GetRelativePath(sourceDir, full).Replace(Path.DirectorySeparatorChar, '/');
+ if (rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase))
+ continue;
var info = new FileInfo(full);
- files.Add(new { name, path = rel, size = info.Length, modified = info.LastWriteTimeUtc });
+ files.Add(new { path = rel, name = Path.GetFileName(full), size = info.Length, modified = info.LastWriteTimeUtc });
}
- foreach (var full in Directory.EnumerateDirectories(dir))
+ foreach (var full in Directory.EnumerateDirectories(fullDir))
{
var name = Path.GetFileName(full);
if (name == ".git") continue;
- var rel = Path.GetRelativePath(cloneDir, full).Replace(Path.DirectorySeparatorChar, '/');
- files.Add(new { name, path = rel, isDir = true });
+ var rel = Path.GetRelativePath(sourceDir, full).Replace(Path.DirectorySeparatorChar, '/');
+ files.Add(new { path = rel, name, isDir = true });
}
return Ok(new { files });
@@ -108,19 +134,11 @@ public class WorkflowFilesController : ControllerBase
[RequireScope("read")]
public IActionResult GetContent([FromQuery] string path)
{
- if (_sourceFactory.Forgejo == null)
- return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
-
if (string.IsNullOrWhiteSpace(path))
return BadRequest(new { error = "path is required" });
- var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(ForgejoLogin);
- var full = Path.Combine(cloneDir, path.Replace('/', Path.DirectorySeparatorChar));
-
- // Reject path traversal.
- var fullCloneDir = Path.GetFullPath(cloneDir);
- var fullFile = Path.GetFullPath(full);
- if (!fullFile.StartsWith(fullCloneDir, StringComparison.Ordinal))
+ var full = ResolveSafeFile(path, out var status);
+ if (status.HasValue)
return BadRequest(new { error = "Invalid path" });
if (!System.IO.File.Exists(full))
@@ -132,26 +150,18 @@ public class WorkflowFilesController : ControllerBase
}
///
- /// Saves content to a file in the tenant's workflow repo.
- /// Writes to the local clone (does not commit/push — use POST /commit for that).
+ /// Saves content to a file in the tenant's workflow repo. Writes to the local
+ /// working tree (no commit/push — use POST /commit in Forgejo mode for that).
///
[HttpPost("save")]
[RequireScope("manage")]
public IActionResult SaveFile([FromBody] SaveWorkflowFileRequest request)
{
- if (_sourceFactory.Forgejo == null)
- return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
-
if (string.IsNullOrWhiteSpace(request.Path))
return BadRequest(new { error = "path is required" });
- var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(ForgejoLogin);
- var full = Path.Combine(cloneDir, request.Path.Replace('/', Path.DirectorySeparatorChar));
-
- // Reject path traversal.
- var fullCloneDir = Path.GetFullPath(cloneDir);
- var fullFile = Path.GetFullPath(full);
- if (!fullFile.StartsWith(fullCloneDir, StringComparison.Ordinal))
+ var full = ResolveSafeFile(request.Path, out var status);
+ if (status.HasValue)
return BadRequest(new { error = "Invalid path" });
try
@@ -161,7 +171,8 @@ public class WorkflowFilesController : ControllerBase
Directory.CreateDirectory(dir);
System.IO.File.WriteAllText(full, request.Content ?? string.Empty, new System.Text.UTF8Encoding(false));
- _logger.LogInformation("Saved workflow file {Path} for user {Login}", request.Path, ForgejoLogin);
+ _logger.LogInformation("Saved workflow file {Path} for tenant {TenantId} ({Login})",
+ request.Path, TenantId, MaybeForgejoLogin ?? "-");
return Ok(new { success = true, path = request.Path });
}
catch (Exception ex)
@@ -171,15 +182,55 @@ public class WorkflowFilesController : ControllerBase
}
}
- /// Returns the git status of the tenant's workflow repo.
+ ///
+ /// Deletes files/directories from the tenant's workflow repo. Used to remove a
+ /// whole workflow (its folder) or an individual file. Returns the paths deleted.
+ ///
+ [HttpPost("delete")]
+ [RequireScope("manage")]
+ public IActionResult DeleteFiles([FromBody] DeleteWorkflowFileRequest? request)
+ {
+ if (request == null || request.Paths == null || request.Paths.Length == 0)
+ return BadRequest(new { error = "paths is required" });
+
+ var sourceDir = SourceDir;
+ var fullSourceDir = Path.GetFullPath(sourceDir);
+ var deleted = new List();
+
+ foreach (var raw in request.Paths)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) continue;
+ var full = Path.GetFullPath(Path.Combine(sourceDir, raw.Replace('/', Path.DirectorySeparatorChar)));
+ if (!full.StartsWith(fullSourceDir, StringComparison.Ordinal) ||
+ full == fullSourceDir)
+ continue;
+
+ if (Directory.Exists(full))
+ Directory.Delete(full, recursive: true);
+ else if (System.IO.File.Exists(full))
+ System.IO.File.Delete(full);
+ else
+ continue;
+ deleted.Add(raw);
+ }
+
+ _logger.LogInformation("Deleted workflow files for tenant {TenantId}: {Paths}",
+ TenantId, string.Join(", ", deleted));
+ return Ok(new { success = true, deleted });
+ }
+
+ /// Returns the git status of the tenant's workflow repo (Forgejo-backed mode only).
[HttpGet("status")]
[RequireScope("read")]
public IActionResult GetStatus()
{
if (_sourceFactory.Forgejo == null)
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
+ var login = MaybeForgejoLogin;
+ if (string.IsNullOrWhiteSpace(login))
+ return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
- var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(ForgejoLogin);
+ var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(login);
if (!Directory.Exists(Path.Combine(cloneDir, ".git")))
return Ok(new { branch = "", changed = Array.Empty(), untracked = Array.Empty() });
@@ -206,41 +257,63 @@ public class WorkflowFilesController : ControllerBase
}
///
- /// Commits all changes and pushes to Forgejo. Optionally provides a commit message.
- /// After committing, call POST /api/workflows/sync to recompile.
+ /// Commits and pushes all changes to Forgejo. A no-op (returns success) in
+ /// filesystem-only mode. After committing, call POST /api/workflows/sync to
+ /// recompile.
///
[HttpPost("commit")]
[RequireScope("manage")]
public async Task CommitAndPush([FromBody] CommitWorkflowRequest? request = null)
{
if (_sourceFactory.Forgejo == null)
- return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
+ return Ok(new { success = true, output = "filesystem mode — no remote to push" });
+ var login = MaybeForgejoLogin;
+ if (string.IsNullOrWhiteSpace(login))
+ return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
var message = string.IsNullOrWhiteSpace(request?.Message)
? $"Update workflow files ({DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC)"
: request!.Message;
var (success, output) = await _sourceFactory.Forgejo.CommitAndPushAsync(
- ForgejoLogin, message, HttpContext.RequestAborted);
+ login, message, HttpContext.RequestAborted);
_logger.LogInformation("Commit/push for login {Login}: {Success} ({Output})",
- ForgejoLogin, success, output?.Trim() ?? "");
+ login, success, output?.Trim() ?? "");
return Ok(new { success, output });
}
- /// Pulls the latest changes from Forgejo.
+ /// Pulls the latest changes from Forgejo (no-op in filesystem-only mode).
[HttpPost("pull")]
[RequireScope("manage")]
public async Task Pull()
{
if (_sourceFactory.Forgejo == null)
- return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
+ return Ok(new { success = true, output = "filesystem mode — no remote to pull" });
+ var login = MaybeForgejoLogin;
+ if (string.IsNullOrWhiteSpace(login))
+ return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
- var ok = await _sourceFactory.Forgejo.PullAsync(ForgejoLogin, HttpContext.RequestAborted);
+ var ok = await _sourceFactory.Forgejo.PullAsync(login, HttpContext.RequestAborted);
return Ok(new { success = ok });
}
+ /// Resolves a repo-relative path to an absolute path inside the tenant source dir.
+ private string ResolveSafeFile(string path, out int? status)
+ {
+ var sourceDir = SourceDir;
+ var full = Path.GetFullPath(Path.Combine(sourceDir, path.Replace('/', Path.DirectorySeparatorChar)));
+ var fullSourceDir = Path.GetFullPath(sourceDir);
+ if (!full.StartsWith(fullSourceDir, StringComparison.Ordinal))
+ {
+ status = 400;
+ return full;
+ }
+ status = null;
+ return full;
+ }
+
private string? RunGit(string workDir, params string[] args)
{
try
@@ -276,3 +349,4 @@ public class WorkflowFilesController : ControllerBase
public sealed record SaveWorkflowFileRequest(string Path, string? Content);
public sealed record CommitWorkflowRequest(string? Message);
+public sealed record DeleteWorkflowFileRequest(string[] Paths);
diff --git a/Services/ForgejoWorkflowRepoService.cs b/Services/ForgejoWorkflowRepoService.cs
index 002e242..2e1690e 100644
--- a/Services/ForgejoWorkflowRepoService.cs
+++ b/Services/ForgejoWorkflowRepoService.cs
@@ -24,7 +24,6 @@ public sealed class ForgejoWorkflowRepoService
private readonly string? _forgejoAdminToken;
private readonly string _forgejoOwner;
private readonly string _copiesRoot;
- private readonly string _sharedDir;
private readonly ILogger _logger;
public ForgejoWorkflowRepoService(IConfiguration config, ILogger logger)
@@ -41,9 +40,11 @@ public sealed class ForgejoWorkflowRepoService
?? throw new InvalidOperationException(
"WorkflowSource:CopiesRoot is not configured. " +
"Set it to a writable directory path (e.g. \"/data/workflow-tenants\")."));
- _sharedDir = config["WorkflowSource:SharedDir"] ?? "workflows";
}
+ /// The Forgejo base URL (for building repo links).
+ public string ForgejoBaseUrl => _forgejoBase;
+
/// True when Forgejo admin provisioning is configured.
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(_forgejoAdminToken) &&
@@ -187,8 +188,11 @@ public sealed class ForgejoWorkflowRepoService
// Clean up any baked-in extra header from clone config.
UnsetLocalConfig(tenantDir, "http.extraheader");
- // If the repo is empty (freshly created with auto_init), seed with templates.
- await SeedTemplatesIfEmptyAsync(tenantDir, ct);
+ // NOTE: we deliberately do NOT seed shared example templates into a fresh
+ // workflow repo. Workflows are strictly per-tenant: a tenant starts with
+ // exactly the content of their own repo (empty for a freshly-created repo)
+ // and builds up their own definitions. Seeding shared examples made every
+ // tenant see the same "predefined" workflows and broke tenant isolation.
return tenantDir;
}
@@ -257,100 +261,6 @@ public sealed class ForgejoWorkflowRepoService
public string TenantCloneDir(string tenantId)
=> Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId)));
- ///
- /// Seeds the local clone with template files from the shared directory.
- /// Only runs when the repo is empty (no workflow YAML files yet).
- ///
- private async Task SeedTemplatesIfEmptyAsync(string cloneDir, CancellationToken ct)
- {
- // Check if the repo already has workflow YAML files.
- var yamlCount = Directory.EnumerateFiles(cloneDir, "*.yaml", SearchOption.AllDirectories)
- .Concat(Directory.EnumerateFiles(cloneDir, "*.yml", SearchOption.AllDirectories))
- .Count(f => !f.Contains(".git", StringComparison.OrdinalIgnoreCase));
-
- if (yamlCount > 0)
- {
- _logger.LogDebug("Clone already has {Count} YAML files, skipping seed", yamlCount);
- return;
- }
-
- var sharedDir = ResolveSharedDir();
- if (!Directory.Exists(sharedDir))
- {
- _logger.LogWarning("Shared workflow directory {Shared} does not exist; clone starts empty", sharedDir);
- return;
- }
-
- _logger.LogInformation("Seeding empty clone from {Shared}", sharedDir);
-
- // Copy ALL files (YAML + code) from the shared directory.
- CopyAllFiles(sharedDir, cloneDir);
-
- // Commit the seeded templates.
- await RunGitAsync(cloneDir, ct, "add", "-A");
- var commitOutput = await RunGitAsync(cloneDir, ct, "commit", "-m", "Initial workflow templates");
- if (commitOutput != null && !commitOutput.Contains("nothing to commit", StringComparison.OrdinalIgnoreCase))
- {
- var token = !string.IsNullOrWhiteSpace(_forgejoToken) ? _forgejoToken : _forgejoAdminToken;
- if (!string.IsNullOrWhiteSpace(token))
- {
- await RunGitAsync(cloneDir, ct, "-c", $"http.extraHeader=Authorization: token {token}", "push");
- }
- else
- {
- await RunGitAsync(cloneDir, ct, "push");
- }
- _logger.LogInformation("Pushed initial templates to Forgejo");
- }
- }
-
- private string ResolveSharedDir()
- {
- var sharedRel = _sharedDir;
- if (Path.IsPathRooted(sharedRel) && Directory.Exists(sharedRel))
- return Path.GetFullPath(sharedRel);
-
- // Try resolving relative to the repo root (walk up from the service dir).
- var start = AppContext.BaseDirectory;
- var walk = Path.GetFullPath(start);
- while (true)
- {
- var candidate = Path.Combine(walk, sharedRel);
- if (Directory.Exists(candidate))
- return Path.GetFullPath(candidate);
- var parent = Directory.GetParent(walk)?.FullName;
- if (string.IsNullOrEmpty(parent) || parent == walk)
- break;
- walk = parent;
- }
-
- return Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), sharedRel));
- }
-
- private static void CopyAllFiles(string src, string dst)
- {
- foreach (var file in Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories))
- {
- var rel = Path.GetRelativePath(src, file);
- if (rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase))
- continue;
-
- var destPath = Path.Combine(dst, rel);
- var destDir = Path.GetDirectoryName(destPath);
- if (destDir != null && !Directory.Exists(destDir))
- Directory.CreateDirectory(destDir);
-
- try
- {
- File.Copy(file, destPath, overwrite: true);
- }
- catch (Exception)
- {
- // Best-effort — template files that fail to copy are non-fatal.
- }
- }
- }
-
private async Task RunGitAsync(string workDir, CancellationToken ct, params string[] args)
{
try
diff --git a/Services/PerTenantWorkflowSource.cs b/Services/PerTenantWorkflowSource.cs
index c69d859..195f864 100644
--- a/Services/PerTenantWorkflowSource.cs
+++ b/Services/PerTenantWorkflowSource.cs
@@ -32,30 +32,23 @@ namespace w4c_workflows.Services;
///
public sealed class WorkflowSourceFactory
{
- private readonly IConfiguration _config;
- private readonly IWebHostEnvironment _env;
private readonly ILoggerFactory _loggers;
private readonly string _copiesRoot;
- private readonly string _sharedDir;
- private readonly string _repoRoot;
private readonly ForgejoWorkflowRepoService? _forgejo;
private readonly NpgsqlDataSource? _ds;
public WorkflowSourceFactory(IConfiguration config, IWebHostEnvironment env, ILoggerFactory loggers,
NpgsqlDataSource? ds = null)
{
- _config = config;
- _env = env;
_loggers = loggers;
_ds = ds;
- _copiesRoot = config["WorkflowSource:CopiesRoot"]
+ _copiesRoot = Path.GetFullPath(
+ 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\").");
- _sharedDir = config["WorkflowSource:SharedDir"] ?? "workflows";
- _repoRoot = ResolveRepoRoot(config, env);
+ "directory path (e.g. \"/data/workflow-tenants\" or \".data/workflow-tenants\")."));
// Create the Forgejo service when admin provisioning is configured.
var forgejoLogger = loggers.CreateLogger();
@@ -84,25 +77,34 @@ public sealed class WorkflowSourceFactory
/// Forgejo repo if needed), then returns a source backed by the clone.
///
/// In filesystem-only mode: the tenant directory
- /// {CopiesRoot}/{sanitizedTenantId}/ is created (and seeded from
- /// the shared template dir) on first access.
+ /// {CopiesRoot}/{sanitizedTenantId}/ is created empty on first access
+ /// (never seeded from shared templates — workflows are strictly per-tenant).
///
public IWorkflowSource Create(string tenantId)
{
- var safeId = Sanitize(tenantId);
- var tenantDir = Path.GetFullPath(Path.Combine(_copiesRoot, safeId));
-
- var sharedRel = _sharedDir;
- string sharedDir;
- if (Path.IsPathRooted(sharedRel) && Directory.Exists(sharedRel))
- sharedDir = Path.GetFullPath(sharedRel);
- else
- sharedDir = Path.GetFullPath(Path.Combine(_repoRoot, sharedRel));
-
+ var tenantDir = TenantSourceDir(tenantId);
var logger = _loggers.CreateLogger();
- return new PerTenantWorkflowSource(tenantId, tenantDir, sharedDir, logger);
+ return new PerTenantWorkflowSource(tenantId, tenantDir, logger);
}
+ ///
+ /// Resolves the absolute on-disk directory that holds a tenant's workflow
+ /// files. In Forgejo-backed mode this is the per-user clone dir; in
+ /// filesystem-only mode it is {CopiesRoot}/{sanitizedTenantId}/ .
+ /// Used by the workflow-file CRUD controller so edits always land in the same
+ /// place reads from.
+ ///
+ public string ResolveTenantSourceDir(string tenantId, string? forgejoLogin = null)
+ {
+ if (_forgejo != null && !string.IsNullOrWhiteSpace(forgejoLogin))
+ return _forgejo.TenantCloneDir(forgejoLogin);
+ return TenantSourceDir(tenantId);
+ }
+
+ /// The per-tenant filesystem source directory (not Forgejo-backed).
+ public string TenantSourceDir(string tenantId)
+ => Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId)));
+
///
/// Creates a per-tenant workflow source asynchronously. In Forgejo-backed
/// mode this ensures the local clone exists (cloning from Forgejo if needed).
@@ -131,7 +133,7 @@ public sealed class WorkflowSourceFactory
await _forgejo.PullAsync(login, ct);
var logger = _loggers.CreateLogger();
- return new PerTenantWorkflowSource(tenantId, cloneDir, _sharedDir, logger);
+ return new PerTenantWorkflowSource(tenantId, cloneDir, logger);
}
///
@@ -170,30 +172,6 @@ public sealed class WorkflowSourceFactory
var result = sb.ToString();
return result.Length > 120 ? result[..120] : result;
}
-
- private static string ResolveRepoRoot(IConfiguration config, IWebHostEnvironment env)
- {
- var configured = config["SourceCode:RepoRoot"];
- if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
- return Path.GetFullPath(configured);
-
- var start = AppContext.BaseDirectory;
- if (!string.IsNullOrEmpty(env.ContentRootPath) && Directory.Exists(env.ContentRootPath))
- start = env.ContentRootPath;
-
- var walk = Path.GetFullPath(start);
- while (true)
- {
- if (Directory.Exists(Path.Combine(walk, ".git")))
- return Path.GetFullPath(walk);
- var parent = Directory.GetParent(walk)?.FullName;
- if (string.IsNullOrEmpty(parent) || parent == walk)
- break;
- walk = parent;
- }
-
- return Path.GetFullPath(Directory.GetCurrentDirectory());
- }
}
///
@@ -204,17 +182,15 @@ public sealed class WorkflowSourceFactory
public sealed class PerTenantWorkflowSource : IWorkflowSource
{
private readonly string _tenantDir;
- private readonly string _sharedDir;
private readonly ILogger _logger;
- public PerTenantWorkflowSource(string tenantId, string tenantDir, string sharedDir, ILogger logger)
+ public PerTenantWorkflowSource(string tenantId, string tenantDir, ILogger logger)
{
_tenantDir = tenantDir;
- _sharedDir = sharedDir;
_logger = logger;
_logger.LogInformation(
- "Per-tenant workflow source: tenant={TenantId} dir={Dir} shared={Shared}",
- tenantId, _tenantDir, _sharedDir);
+ "Per-tenant workflow source: tenant={TenantId} dir={Dir}",
+ tenantId, _tenantDir);
}
public Task> ListAsync(CancellationToken ct)
@@ -242,24 +218,17 @@ public sealed class PerTenantWorkflowSource : IWorkflowSource
}
///
- /// Ensures the tenant directory exists and has been seeded from the shared
- /// template directory (first-access initialization).
+ /// Ensures the tenant directory exists. Workflows are strictly per-tenant:
+ /// we never copy shared example templates into a tenant's directory, so a
+ /// tenant sees only the definitions that belong to them.
///
private void EnsureInitialized()
{
if (Directory.Exists(_tenantDir))
return;
- _logger.LogInformation("Initializing tenant workflow directory {Dir} from {Shared}", _tenantDir, _sharedDir);
+ _logger.LogInformation("Initializing (empty) tenant workflow directory {Dir}", _tenantDir);
Directory.CreateDirectory(_tenantDir);
-
- if (!Directory.Exists(_sharedDir))
- {
- _logger.LogWarning("Shared workflow directory {Shared} does not exist; tenant starts empty", _sharedDir);
- return;
- }
-
- CopyYamlFiles(_sharedDir, _tenantDir, _logger);
}
private static IReadOnlyList ListYamlFiles(string dir)
@@ -283,37 +252,6 @@ public sealed class PerTenantWorkflowSource : IWorkflowSource
return files.OrderBy(f => f.Path, StringComparer.Ordinal).ToList();
}
- ///
- /// Copies YAML files from src to dst, skipping .git and non-yaml files.
- ///
- private static void CopyYamlFiles(string src, string dst, ILogger logger)
- {
- foreach (var file in Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories))
- {
- var ext = Path.GetExtension(file);
- if (ext is not (".yaml" or ".yml"))
- continue;
-
- var rel = Path.GetRelativePath(src, file);
- if (rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase))
- continue;
-
- var destPath = Path.Combine(dst, rel);
- var destDir = Path.GetDirectoryName(destPath);
- if (destDir != null && !Directory.Exists(destDir))
- Directory.CreateDirectory(destDir);
-
- try
- {
- File.Copy(file, destPath, overwrite: true);
- }
- catch (Exception ex)
- {
- logger.LogWarning(ex, "Failed to copy template file {File}", rel);
- }
- }
- }
-
private string? RunGit(string workDir, params string[] args)
{
try