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