From 4ad6cc2cb9f32749e4c0ede93ad12a9cb7b444d9 Mon Sep 17 00:00:00 2001 From: Vitali sharp8n Date: Thu, 3 Sep 2026 17:44:39 +0300 Subject: [PATCH] workflow source mapping --- Controllers/WorkflowFilesController.cs | 67 ++++++++++++-- Controllers/WorkflowsController.cs | 3 +- Data/WorkflowsDbContext.cs | 25 ++++++ Models/Entities.cs | 21 +++++ Program.cs | 29 ++++++- Services/Execution/CSharpScriptExecutor.cs | 33 +++++-- Services/ForgejoWorkflowRepoService.cs | 66 +++++++++----- Services/PerTenantWorkflowSource.cs | 22 +++-- Services/Runs/TaskDispatcher.cs | 29 ++++--- Services/WorkflowCompiler.cs | 21 ++--- Services/WorkflowRepoStore.cs | 76 ++++++++++++++++ Services/WorkflowSyncService.cs | 10 ++- appsettings.json | 3 +- .../CSharpScriptExecutorTests.cs | 59 +++++++++++++ .../TaskDispatcherWorkingDirTests.cs | 87 +++++++++++++++++++ .../WorkflowCompilerTests.cs | 2 +- .../WorkflowSyncIntegrationTests.cs | 18 ++-- .../WorkflowsPostgresFixture.cs | 7 ++ 18 files changed, 499 insertions(+), 79 deletions(-) create mode 100644 Services/WorkflowRepoStore.cs create mode 100644 w4c-workflows-api.Tests/TaskDispatcherWorkingDirTests.cs diff --git a/Controllers/WorkflowFilesController.cs b/Controllers/WorkflowFilesController.cs index 7f48826..81be7e6 100644 --- a/Controllers/WorkflowFilesController.cs +++ b/Controllers/WorkflowFilesController.cs @@ -28,17 +28,23 @@ namespace w4c_workflows.Controllers; [Route("api/workflow-files")] public class WorkflowFilesController : ControllerBase { - private readonly WorkflowSourceFactory _sourceFactory; - private readonly ILogger _logger; - public WorkflowFilesController( WorkflowSourceFactory sourceFactory, + WorkflowRepoStore repoStore, + WorkflowSyncService sync, ILogger logger) { _sourceFactory = sourceFactory; + _repoStore = repoStore; + _sync = sync; _logger = logger; } + private readonly WorkflowSourceFactory _sourceFactory; + private readonly WorkflowRepoStore _repoStore; + private readonly WorkflowSyncService _sync; + private readonly ILogger _logger; + private string TenantId => (string?)HttpContext.Items["TenantId"] ?? throw new InvalidOperationException("TenantId not resolved by auth middleware"); @@ -58,7 +64,13 @@ public class WorkflowFilesController : ControllerBase /// reads from, so file edits made through these endpoints are what get compiled /// on sync. /// - private string SourceDir => _sourceFactory.ResolveTenantSourceDir(TenantId, MaybeForgejoLogin); + private string SourceDir => _sourceFactory.ResolveTenantSourceDir( + TenantId, MaybeForgejoLogin, CurrentRepo); + + /// The tenant's current workflow repo name (set by the source middleware). + private string CurrentRepo => + (HttpContext.Items["WorkflowRepo"] as string) + ?? (_sourceFactory.Forgejo?.WorkflowRepoName ?? "workflows"); /// Returns the Forgejo repo info for this tenant's workflow files. [HttpGet("repo")] @@ -71,12 +83,15 @@ 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(TenantId, login); - var fullName = ForgejoWorkflowRepoService.RepoFullNameForLogin(login); + var repoName = _repoStore.DefaultName; + try { repoName = CurrentRepo; } catch { /* keep default */ } + var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(TenantId, login, repoName); + var fullName = _sourceFactory.Forgejo.RepoFullNameForLogin(login, repoName); var exists = Directory.Exists(Path.Combine(cloneDir, ".git")); return Ok(new { + repoName, repoFullName = fullName, cloneDir, cloned = exists, @@ -84,6 +99,43 @@ public class WorkflowFilesController : ControllerBase }); } + /// + /// Sets the tenant's workflow repository (basename, e.g. workflows or + /// a user-selected repo) and re-syncs. Switching repos unloads workflows + /// compiled from any previously-selected repo (they are no longer listed). + /// + [HttpPut("repo")] + [RequireScope("manage")] + public async Task SetRepo([FromBody] SetWorkflowRepoRequest? request, CancellationToken ct) + { + if (request == null || string.IsNullOrWhiteSpace(request.RepoName)) + return BadRequest(new { error = "repoName is required" }); + + var repoName = WorkflowRepoStore.NormalizeName(request.RepoName); + await _repoStore.SetNameAsync(TenantId, repoName, ct); + + // Re-sync workflows for the newly selected repo so the list reflects it. + var syncResult = await _sync.SyncAsync(TenantId, repoName, ct); + + var login = MaybeForgejoLogin; + string? fullName = null, cloneDir = null; + if (_sourceFactory.Forgejo != null && !string.IsNullOrWhiteSpace(login)) + { + cloneDir = _sourceFactory.Forgejo.TenantCloneDir(TenantId, login, repoName); + fullName = _sourceFactory.Forgejo.RepoFullNameForLogin(login, repoName); + } + + return Ok(new + { + repoName, + repoFullName = fullName, + cloneDir, + compiled = syncResult.Compiled, + removed = syncResult.Removed, + errors = syncResult.Errors, + }); + } + /// /// Lists all files under a directory (recursive, excluding .git), plus the /// top-level subdirectories. Returns repo-relative paths. @@ -230,7 +282,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(TenantId, login); + var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(TenantId, login, CurrentRepo); if (!Directory.Exists(Path.Combine(cloneDir, ".git"))) return Ok(new { branch = "", changed = Array.Empty(), untracked = Array.Empty() }); @@ -347,6 +399,7 @@ public class WorkflowFilesController : ControllerBase } } +public sealed record SetWorkflowRepoRequest(string? RepoName); public sealed record SaveWorkflowFileRequest(string Path, string? Content); public sealed record CommitWorkflowRequest(string? Message); public sealed record DeleteWorkflowFileRequest(string[] Paths); diff --git a/Controllers/WorkflowsController.cs b/Controllers/WorkflowsController.cs index 8d5c717..f372a76 100644 --- a/Controllers/WorkflowsController.cs +++ b/Controllers/WorkflowsController.cs @@ -307,7 +307,8 @@ public class WorkflowsController : ControllerBase [RequireScope("manage")] public async Task Sync(CancellationToken ct) { - var result = await _sync.SyncAsync(TenantId, ct); + var repoName = (string?)HttpContext.Items["WorkflowRepo"] ?? "workflows"; + var result = await _sync.SyncAsync(TenantId, repoName, ct); _logger.LogInformation( "Workflow sync for tenant {TenantId}: {Compiled} compiled, {Removed} removed, {Errors} errors", TenantId, result.Compiled, result.Removed, result.Errors.Count); diff --git a/Data/WorkflowsDbContext.cs b/Data/WorkflowsDbContext.cs index 94ad91f..de1059a 100644 --- a/Data/WorkflowsDbContext.cs +++ b/Data/WorkflowsDbContext.cs @@ -10,6 +10,16 @@ public class WorkflowsDbContext : DbContext { } + /// + /// The tenant's current workflow repo name (set per request by the source + /// middleware, e.g. workflows or a user-selected repo). Every + /// query is scoped to this repo, so switching repos + /// "unloads" workflows that belong to a previously-selected repo without + /// deleting their history. Null in background/worker scopes (no scoping), + /// which is the same repo as the current tenant's selection. + /// + public string? CurrentRepo { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); @@ -28,6 +38,7 @@ public class WorkflowsDbContext : DbContext public DbSet TaskRuns => Set(); public DbSet DurableStates => Set(); public DbSet ApiKeys => Set(); + public DbSet WorkflowRepos => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -42,12 +53,19 @@ public class WorkflowsDbContext : DbContext e.Property(w => w.Name).HasMaxLength(200).IsRequired(); e.Property(w => w.Path).HasMaxLength(500).IsRequired(); e.Property(w => w.GitSha).HasMaxLength(64); + e.Property(w => w.Repo).HasMaxLength(120).IsRequired().HasDefaultValue("workflows"); e.Property(w => w.Status).HasMaxLength(32).IsRequired(); e.Property(w => w.Mode).HasMaxLength(32).IsRequired(); e.Property(w => w.Target).HasMaxLength(100).IsRequired(); e.Property(w => w.TriggerJson).HasColumnType("jsonb"); e.HasIndex(w => new { w.TenantId, w.Name }).IsUnique(); e.HasIndex(w => w.TenantId); + + // Every active-query works against the tenant's CURRENT workflow repo. + // Workflows from a previously-selected repo are excluded (unloaded) but + // preserved for history. Background/worker scopes (CurrentRepo == null) + // see all; the sync service scopes itself explicitly by repo. + e.HasQueryFilter(w => CurrentRepo == null || w.Repo == CurrentRepo); }); modelBuilder.Entity(e => @@ -129,5 +147,12 @@ public class WorkflowsDbContext : DbContext e.Property(k => k.ScopesJson).HasColumnType("jsonb"); e.HasIndex(k => k.TenantId); }); + + modelBuilder.Entity(e => + { + e.HasKey(r => r.TenantId); + e.Property(r => r.TenantId).HasMaxLength(120).IsRequired(); + e.Property(r => r.RepoName).HasMaxLength(120).IsRequired(); + }); } } diff --git a/Models/Entities.cs b/Models/Entities.cs index b122d61..184f2c3 100644 --- a/Models/Entities.cs +++ b/Models/Entities.cs @@ -68,6 +68,14 @@ public class Workflow public required string Name { get; set; } public required string Path { get; set; } public string? GitSha { get; set; } + /// + /// The repository name (basename) this workflow was compiled from, e.g. + /// workflows (default) or a user-selected repo like wiz4apps. + /// The workflows module only lists / runs workflows whose + /// matches the tenant's current workflow repo; switching repos "unloads" + /// workflows from other repos without deleting their history. + /// + public string Repo { get; set; } = "workflows"; public required string Status { get; set; } // compiled | invalid public required string Mode { get; set; } // function | durable | handler public string? TriggerJson { get; set; } // jsonb: { type, cron, interval, webhookPath, stream } @@ -202,6 +210,19 @@ public class DurableState public string? CorrelationId { get; set; } } +/// +/// Per-tenant workflow repo setting. Records which repository (basename) is the +/// tenant's current workflow repo; the repository holds the workflow YAML + +/// sibling code files. Defaults to the configured WorkflowSource:WorkflowRepoName +/// ("workflows") when no row exists. +/// +public class TenantWorkflowRepo +{ + public required string TenantId { get; set; } + public required string RepoName { get; set; } + public DateTime? UpdatedAt { get; set; } +} + /// /// Per-tenant operator API key. Only the SHA-256 hash is stored; the raw key is /// shown once at mint time. diff --git a/Program.cs b/Program.cs index eabb450..af4ecee 100644 --- a/Program.cs +++ b/Program.cs @@ -126,6 +126,7 @@ builder.Services.AddSingleton(); // When Forgejo:AdminToken is configured, the factory creates Forgejo-backed // sources with per-tenant repos; otherwise it falls back to filesystem-only. builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddScoped(sp => { var http = sp.GetRequiredService(); @@ -250,12 +251,30 @@ app.Use(async (context, next) => var tenantId = context.Items["TenantId"] as string; if (!string.IsNullOrEmpty(tenantId)) { + // Scope all workflow queries to the tenant's CURRENT workflow repo, so a + // workflow compiled from a previously-selected repo is unloaded/disabled. + var repoName = (context.RequestServices.GetRequiredService()["WorkflowSource:WorkflowRepoName"] ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(repoName)) repoName = "workflows"; + try + { + var store = context.RequestServices.GetRequiredService(); + repoName = await store.GetNameAsync(tenantId, context.RequestAborted); + } + catch (Exception ex) + { + var l = context.RequestServices.GetRequiredService().CreateLogger("WorkflowSourceMiddleware"); + l.LogDebug(ex, "Could not resolve workflow repo for tenant {TenantId}", tenantId); + } + var db = context.RequestServices.GetRequiredService(); + db.CurrentRepo = repoName; + context.Items["WorkflowRepo"] = repoName; + var factory = context.RequestServices.GetRequiredService(); if (factory.IsForgejoBacked) { try { - var source = await factory.CreateAsync(tenantId, context.RequestAborted); + var source = await factory.CreateAsync(tenantId, repoName, context.RequestAborted); context.Items["WorkflowSource"] = source; // Store the resolved Forgejo login for controllers that need // to resolve repo paths (WorkflowFilesController, etc.). @@ -290,6 +309,14 @@ try "ALTER TABLE workflows.\"Tasks\" ADD COLUMN IF NOT EXISTS \"ArchivedAt\" timestamptz NULL;"); await db.Database.ExecuteSqlRawAsync( "ALTER TABLE workflows.\"Tasks\" ADD COLUMN IF NOT EXISTS \"Server\" text NULL;"); + // Per-tenant workflow repo setting (which repo holds the workflow files) + + // which repo a compiled workflow came from. Applied idempotently. + await db.Database.ExecuteSqlRawAsync( + "ALTER TABLE workflows.\"Workflows\" ADD COLUMN IF NOT EXISTS \"Repo\" text NOT NULL DEFAULT 'workflows';"); + await db.Database.ExecuteSqlRawAsync( + "CREATE TABLE IF NOT EXISTS workflows.\"WorkflowRepos\" (" + + "\"TenantId\" text NOT NULL, \"RepoName\" text NOT NULL, \"UpdatedAt\" timestamptz NULL, " + + "CONSTRAINT \"PK_WorkflowRepos\" PRIMARY KEY (\"TenantId\"));"); } } catch (Exception ex) diff --git a/Services/Execution/CSharpScriptExecutor.cs b/Services/Execution/CSharpScriptExecutor.cs index 68cf69d..6af1e0c 100644 --- a/Services/Execution/CSharpScriptExecutor.cs +++ b/Services/Execution/CSharpScriptExecutor.cs @@ -25,8 +25,9 @@ namespace w4c_workflows.Services.Execution; /// /// Entry contract (v1): a public static method named after /// entry.function (default Main) that takes a single -/// string (the JSON input) or no arguments, returning a value serialized -/// to JSON as the task output (sync or Task/Task<T>). +/// string or object/dynamic argument (the JSON input) or +/// no arguments, returning a value serialized to JSON as the task output (sync +/// or Task/Task<T>). /// public class CSharpScriptExecutor : IScriptExecutor { @@ -133,11 +134,25 @@ public class CSharpScriptExecutor : IScriptExecutor if (_cachedReferences is { } cached2) return cached2; + // Assemblies injected by dotnet-watch hot reload (Edit and Continue) + // contain duplicate metadata keys that crash Roslyn's internal cache. + // Skip them — user code never references these types directly. + static bool IsHotReloadAssembly(Assembly asm) + { + var name = asm.GetName().Name; + if (string.IsNullOrEmpty(name)) + return false; + return name.StartsWith("Microsoft.CodeAnalysis", StringComparison.Ordinal) + || name.StartsWith("System.Reflection.Metadata", StringComparison.Ordinal) + || name.Contains("HotReload", StringComparison.OrdinalIgnoreCase) + || name.Contains("EditAndContinue", StringComparison.OrdinalIgnoreCase); + } + var references = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) { - if (asm.IsDynamic) + if (asm.IsDynamic || IsHotReloadAssembly(asm)) continue; var location = asm.Location; if (string.IsNullOrEmpty(location) || !File.Exists(location) || !seen.Add(location)) @@ -169,9 +184,17 @@ public class CSharpScriptExecutor : IScriptExecutor if (!candidate.IsStatic || !string.Equals(candidate.Name, function, StringComparison.Ordinal)) continue; var parameters = candidate.GetParameters(); - if (parameters.Length == 1 && parameters[0].ParameterType == typeof(string)) + // Accept a single JSON-input parameter typed as `string` or + // `object`. The frontend/LLM stubs declare `dynamic input`, which + // compiles to `object` at runtime — a `string`-only match would + // reject every UI/AI-generated entry. Both receive the raw JSON + // as the argument; a `dynamic`/`object` param lets the author + // parse it or echo it back. + var singleInput = parameters.Length == 1 + && (parameters[0].ParameterType == typeof(string) || parameters[0].ParameterType == typeof(object)); + if (singleInput) { - method = candidate; // best match — one string parameter + method = candidate; // best match — one JSON-input parameter break; } method ??= parameters.Length == 0 ? candidate : null; diff --git a/Services/ForgejoWorkflowRepoService.cs b/Services/ForgejoWorkflowRepoService.cs index 7a5205d..85df995 100644 --- a/Services/ForgejoWorkflowRepoService.cs +++ b/Services/ForgejoWorkflowRepoService.cs @@ -46,6 +46,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\").")); + // 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. + var repoName = config["WorkflowSource:WorkflowRepoName"]?.Trim(); + WorkflowRepoName = string.IsNullOrWhiteSpace(repoName) ? "workflows" : repoName; } /// The Forgejo base URL (for building repo links). @@ -68,33 +73,46 @@ public sealed class ForgejoWorkflowRepoService !string.IsNullOrWhiteSpace(_forgejoOwner); /// The repo full name on Forgejo (owner/repo-name). - public string RepoFullName(string tenantId) => - $"{_forgejoOwner.ToLowerInvariant()}/{RepoName(tenantId)}"; - - /// The sanitized repo name for a tenant. - public static string RepoName(string tenantId) => - $"workflows-{Sanitize(tenantId)}"; + /// + /// The repository name (basename) used for a tenant's workflow repo. Default + /// is workflows (the repo full name {login}/workflows), so a + /// tenant's workflows live in a repo whose name contains "workflow". A tenant + /// may select a different repo (e.g. wiz4apps) to share the directory + /// with the main-api Source Code explorer; the selection is persisted per + /// tenant and overrides this default. + /// + public string WorkflowRepoName { get; } /// - /// Resolves the Forgejo repo full name for a user. The repo lives under - /// the user's own Forgejo account: {login}/workflows-{login}. + /// Consolidated repo-name resolver. Both the workflows-api engine and the + /// webapi source-code explorer operate on a local clone whose directory name is + /// the slug of the repo full name ({owner}_{repo}). Deriving full name AND + /// slug from a single repo name is what keeps the two services pointing at the + /// same directory. /// - public static string RepoFullNameForLogin(string login) => - $"{login.ToLowerInvariant()}/workflows-{Sanitize(login.ToLowerInvariant())}"; + public static (string RepoFullName, string RepoSlug) ResolveWorkflowRepo(string login, string repoName) + { + var owner = Sanitize(login.ToLowerInvariant()); + var repo = Sanitize(repoName); + return ($"{owner}/{repo}", $"{owner}_{repo}"); + } + + /// + /// Resolves the Forgejo repo full name for a user given the configured/selected + /// workflow repo name: {login}/{repoName}. + /// + public string RepoFullNameForLogin(string login, string? repoName = null) => + ResolveWorkflowRepo(login, repoName ?? WorkflowRepoName).RepoFullName; /// /// The source-copies owner-repo slug for a user's workflow repo, matching the /// webapi SourceCodeCopyService layout ({owner}_{repo}, e.g. - /// test_workflows-test). The workflow repo is {login}/workflows-{login}, - /// so the slug is {login}_workflows-{login}. 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. + /// test_workflows). 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. /// - public static string OwnerRepoSlug(string login) - { - var owner = login.ToLowerInvariant(); - return $"{Sanitize(owner)}_workflows-{Sanitize(owner)}"; - } + public string OwnerRepoSlug(string login, string? repoName = null) => + ResolveWorkflowRepo(login, repoName ?? WorkflowRepoName).RepoSlug; /// /// Ensures the user's Forgejo repo exists. Creates it via the admin API @@ -109,7 +127,7 @@ public sealed class ForgejoWorkflowRepoService "Set Forgejo:AdminToken and Forgejo:Owner (or Forgejo:WorkflowRepoOwner)."); var owner = login.ToLowerInvariant(); - var name = $"workflows-{Sanitize(owner)}"; + var name = Sanitize(WorkflowRepoName); var fullName = $"{owner}/{name}"; try @@ -163,9 +181,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. /// - public async Task EnsureCloneAsync(string tenantId, string login, CancellationToken ct = default) + public async Task EnsureCloneAsync(string tenantId, string login, string? repoName = null, CancellationToken ct = default) { - var tenantDir = TenantCloneDir(tenantId, login); + var tenantDir = TenantCloneDir(tenantId, login, repoName); if (Directory.Exists(Path.Combine(tenantDir, ".git"))) { @@ -303,8 +321,8 @@ public sealed class ForgejoWorkflowRepoService /// on this one working copy, so workflow edits and the source-code view stay /// consistent without a manual git pull. /// - public string TenantCloneDir(string tenantId, string login) - => Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId), OwnerRepoSlug(login))); + public string TenantCloneDir(string tenantId, string login, string? repoName = null) + => Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId), OwnerRepoSlug(login, repoName))); private async Task RunGitAsync(string workDir, CancellationToken ct, params string[] args) { diff --git a/Services/PerTenantWorkflowSource.cs b/Services/PerTenantWorkflowSource.cs index 97f62a7..886d8f0 100644 --- a/Services/PerTenantWorkflowSource.cs +++ b/Services/PerTenantWorkflowSource.cs @@ -94,10 +94,10 @@ public sealed class WorkflowSourceFactory /// 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) + public string ResolveTenantSourceDir(string tenantId, string? forgejoLogin = null, string? repoName = null) { if (_forgejo != null && !string.IsNullOrWhiteSpace(forgejoLogin)) - return _forgejo.TenantCloneDir(tenantId, forgejoLogin); + return _forgejo.TenantCloneDir(tenantId, forgejoLogin, repoName); return TenantSourceDir(tenantId); } @@ -110,13 +110,13 @@ public sealed class WorkflowSourceFactory /// mode this ensures the local clone exists (cloning from Forgejo if needed). /// In filesystem-only mode, delegates to . /// - public async Task CreateAsync(string tenantId, CancellationToken ct = default) + public async Task CreateAsync(string tenantId, string? repoName = null, CancellationToken ct = default) { if (_forgejo == null) return Create(tenantId); // Resolve the user's Forgejo login from the tenant ID (forgejo_id). - // The repo lives under the user's own account: {login}/workflows-{login}. + // The repo lives under the user's own account: {login}/{workflowRepoName}. var login = await ResolveForgejoLoginAsync(tenantId, ct); if (string.IsNullOrEmpty(login)) { @@ -127,7 +127,7 @@ public sealed class WorkflowSourceFactory } // Ensure the Forgejo repo exists and is cloned locally. - var cloneDir = await _forgejo.EnsureCloneAsync(tenantId, login, ct); + var cloneDir = await _forgejo.EnsureCloneAsync(tenantId, login, repoName, ct); // Pull latest changes before reading. await _forgejo.PullAsync(tenantId, login, ct); @@ -228,7 +228,17 @@ public sealed class PerTenantWorkflowSource : IWorkflowSource return; _logger.LogInformation("Initializing (empty) tenant workflow directory {Dir}", _tenantDir); - Directory.CreateDirectory(_tenantDir); + try + { + Directory.CreateDirectory(_tenantDir); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Cannot create tenant workflow directory '{_tenantDir}'. " + + "Check that WorkflowSource:CopiesRoot points to a writable location " + + $"and that the process has filesystem permissions. {ex.Message}", ex); + } } private static IReadOnlyList ListYamlFiles(string dir) diff --git a/Services/Runs/TaskDispatcher.cs b/Services/Runs/TaskDispatcher.cs index 127f5c7..edcf5fd 100644 --- a/Services/Runs/TaskDispatcher.cs +++ b/Services/Runs/TaskDispatcher.cs @@ -183,24 +183,31 @@ public class TaskDispatcher private string ResolveWorkerRootForTenant(string tenantId) { - // Filesystem-only mode: use the global worker root. + // Resolve the tenant's OWN source directory — the exact directory the + // workflow-file writer (WorkflowFilesController -> + // WorkflowSourceFactory.ResolveTenantSourceDir) stores files in. Falling + // back to the global/git root never matched {CopiesRoot}/{tenantId}, so + // every task ran against a working dir that did not contain its entry + // file (surfacing as "entry file not found: Main.cs" in the C# executor). + if (_sourceFactory != null) + return _sourceFactory.ResolveTenantSourceDir(tenantId); return _defaultWorkerRoot; } private async Task 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) - { - var login = await _sourceFactory.ResolveForgejoLoginAsync(tenantId, ct); - if (!string.IsNullOrEmpty(login)) - return _sourceFactory.Forgejo.TenantCloneDir(tenantId, login); + // Same contract as the sync variant: the run's code root must be the one + // the writer used. In Forgejo-backed mode this is the tenant's shared + // local clone (which depends on the user's Forgejo login); otherwise it + // is the per-tenant filesystem dir. Both are exactly where the writer + // lands, so the entry file is always found relative to this root. + if (_sourceFactory == null) return _defaultWorkerRoot; - } - // Filesystem-only mode: use the global worker root. - return _defaultWorkerRoot; + var login = _sourceFactory.IsForgejoBacked + ? await _sourceFactory.ResolveForgejoLoginAsync(tenantId, ct) + : null; + return _sourceFactory.ResolveTenantSourceDir(tenantId, login); } private static string ResolveWorkerRoot(IConfiguration config) diff --git a/Services/WorkflowCompiler.cs b/Services/WorkflowCompiler.cs index 56a986a..1df886a 100644 --- a/Services/WorkflowCompiler.cs +++ b/Services/WorkflowCompiler.cs @@ -41,7 +41,7 @@ public class WorkflowCompiler .Build(); } - public CompileResult Compile(string yaml, string path, string tenantId) + public CompileResult Compile(string yaml, string path, string tenantId, string repoName = "workflows") { var result = new CompileResult(); @@ -60,22 +60,23 @@ public class WorkflowCompiler if (!result.Success) return result; - result.Workflow = Build(def, path, tenantId); + result.Workflow = Build(def, path, tenantId, repoName); return result; } - private CompiledWorkflow Build(WorkflowDefinition def, string path, string tenantId) + private CompiledWorkflow Build(WorkflowDefinition def, string path, string tenantId, string repoName) { var graph = WorkflowGraph.Compute(def); var now = DateTime.UtcNow; - var workflowId = DeterministicGuid.For($"wf::{tenantId}::{path}"); + var workflowId = DeterministicGuid.For($"wf::{tenantId}::{repoName}::{path}"); var workflow = new Workflow { Id = workflowId, TenantId = tenantId, Name = def.Name!, Path = path, + Repo = repoName, Status = WorkflowStatus.Compiled, Mode = def.Mode!, Version = string.IsNullOrWhiteSpace(def.Version) ? "1.0.0" : def.Version!, @@ -89,10 +90,10 @@ public class WorkflowCompiler var tasks = new List(); // Root task = the workflow entry (language/mode/entry/env from the header). - var rootId = DeterministicGuid.For($"task::{tenantId}::{path}::root"); + var rootId = DeterministicGuid.For($"task::{tenantId}::{repoName}::{path}::root"); var headId = graph.HeadId == null ? (Guid?)null - : DeterministicGuid.For($"task::{tenantId}::{path}::{graph.HeadId}"); + : DeterministicGuid.For($"task::{tenantId}::{repoName}::{path}::{graph.HeadId}"); tasks.Add(new WorkflowTask { Id = rootId, @@ -116,7 +117,7 @@ public class WorkflowCompiler for (var i = 0; i < graph.Tasks.Count; i++) { var task = graph.Tasks[i]; - var taskId = DeterministicGuid.For($"task::{tenantId}::{path}::{task.Id}"); + var taskId = DeterministicGuid.For($"task::{tenantId}::{repoName}::{path}::{task.Id}"); tasks.Add(new WorkflowTask { @@ -125,13 +126,13 @@ public class WorkflowCompiler Key = task.Id!, ParentId = string.IsNullOrEmpty(task.Parent) || task.Parent == "root" ? rootId - : DeterministicGuid.For($"task::{tenantId}::{path}::{task.Parent}"), + : DeterministicGuid.For($"task::{tenantId}::{repoName}::{path}::{task.Parent}"), NextId = string.IsNullOrEmpty(task.Next) ? null - : DeterministicGuid.For($"task::{tenantId}::{path}::{task.Next}"), + : DeterministicGuid.For($"task::{tenantId}::{repoName}::{path}::{task.Next}"), OnErrorId = string.IsNullOrEmpty(task.OnError) ? null - : DeterministicGuid.For($"task::{tenantId}::{path}::{task.OnError}"), + : DeterministicGuid.For($"task::{tenantId}::{repoName}::{path}::{task.OnError}"), Language = task.Language ?? def.Language!, Mode = task.Mode ?? def.Mode!, EntryJson = JsonSerializer.Serialize(new EntryDefinition diff --git a/Services/WorkflowRepoStore.cs b/Services/WorkflowRepoStore.cs new file mode 100644 index 0000000..6e83689 --- /dev/null +++ b/Services/WorkflowRepoStore.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using w4c_workflows.Data; +using w4c_workflows.Models; + +namespace w4c_workflows.Services; + +/// +/// Reads and updates the tenant's selected workflow repository name. The repo name +/// is a basename (e.g. workflows by default, or a user-selected repo like +/// wiz4apps). The full repo is {login}/{repoName}; its local clone dir +/// is {CopiesRoot}/{tenantId}/{login}_{repoName} (see +/// ). +/// +/// The selection is durable per tenant (table WorkflowRepos) and falls back +/// to WorkflowSource:WorkflowRepoName (default workflows) when no row +/// exists — so a fresh tenant automatically uses the repository named "workflow". +/// +public sealed class WorkflowRepoStore +{ + private readonly WorkflowsDbContext _db; + private readonly string _defaultName; + + public WorkflowRepoStore(WorkflowsDbContext db, Microsoft.Extensions.Configuration.IConfiguration config) + { + _db = db; + var configured = config["WorkflowSource:WorkflowRepoName"]?.Trim(); + _defaultName = string.IsNullOrWhiteSpace(configured) ? "workflows" : configured; + } + + /// The configured default repo name (used when no per-tenant row exists). + public string DefaultName => _defaultName; + + /// Resolves the tenant's current workflow repo name (stored or default). + public async Task GetNameAsync(string tenantId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(tenantId)) return _defaultName; + var row = await _db.WorkflowRepos.AsNoTracking().FirstOrDefaultAsync(r => r.TenantId == tenantId, ct); + return string.IsNullOrWhiteSpace(row?.RepoName) ? _defaultName : row!.RepoName; + } + + /// + /// Persists the tenant's workflow repo name. Returns the normalized name (trimmed, + /// no leading/trailing slashes). + /// + public async Task SetNameAsync(string tenantId, string repoName, CancellationToken ct = default) + { + var clean = (repoName ?? string.Empty).Trim().Trim('/'); + if (string.IsNullOrWhiteSpace(clean)) + throw new ArgumentException("Workflow repo name is required", nameof(repoName)); + + var row = await _db.WorkflowRepos.FirstOrDefaultAsync(r => r.TenantId == tenantId, ct); + if (row == null) + { + _db.WorkflowRepos.Add(new TenantWorkflowRepo + { + TenantId = tenantId, + RepoName = clean, + UpdatedAt = DateTime.UtcNow, + }); + } + else + { + row.RepoName = clean; + row.UpdatedAt = DateTime.UtcNow; + } + await _db.SaveChangesAsync(ct); + return clean; + } + + /// Validates a repo basename (letters/digits/._-), returns normalized or null. + public static string NormalizeName(string? repoName) + { + var clean = (repoName ?? string.Empty).Trim().Trim('/'); + return string.IsNullOrWhiteSpace(clean) ? "workflows" : clean; + } +} diff --git a/Services/WorkflowSyncService.cs b/Services/WorkflowSyncService.cs index 6c71591..1cdb0b2 100644 --- a/Services/WorkflowSyncService.cs +++ b/Services/WorkflowSyncService.cs @@ -47,7 +47,7 @@ public class WorkflowSyncService _sourceFactory = sourceFactory; } - public async Task SyncAsync(string tenantId, CancellationToken ct) + public async Task SyncAsync(string tenantId, string repoName, CancellationToken ct) { // In Forgejo-backed mode, pull the latest changes before reading files. if (_sourceFactory?.IsForgejoBacked == true && _sourceFactory.Forgejo != null) @@ -63,9 +63,13 @@ public class WorkflowSyncService var result = new SyncResult { HeadSha = state.HeadSha, Dirty = state.Dirty }; + // Only the CURRENT repo's workflows are compiled/kept active. Workflows from + // a previously-selected repo stay in the DB but are excluded from active + // listings (their Repo differs), so switching repos unloads them + // without destroying their history. var existing = await _db.Workflows .Include(w => w.Tasks) - .Where(w => w.TenantId == tenantId) + .Where(w => w.TenantId == tenantId && w.Repo == repoName) .ToListAsync(ct); var existingByPath = existing.ToDictionary(w => w.Path, StringComparer.Ordinal); @@ -89,7 +93,7 @@ public class WorkflowSyncService continue; } - var compiled = _compiler.Compile(content, file.Path, tenantId); + var compiled = _compiler.Compile(content, file.Path, tenantId, repoName); if (!compiled.Success) { result.Errors.Add(new SyncError(file.Path, compiled.Errors)); diff --git a/appsettings.json b/appsettings.json index 6ecf270..dea9393 100644 --- a/appsettings.json +++ b/appsettings.json @@ -69,6 +69,7 @@ }, "WorkflowSource": { "CopiesRoot": "../source-copies", - "SharedDir": "workflows" + "SharedDir": "workflows", + "WorkflowRepoName": "workflows" } } diff --git a/w4c-workflows-api.Tests/CSharpScriptExecutorTests.cs b/w4c-workflows-api.Tests/CSharpScriptExecutorTests.cs index 2892f68..783033e 100644 --- a/w4c-workflows-api.Tests/CSharpScriptExecutorTests.cs +++ b/w4c-workflows-api.Tests/CSharpScriptExecutorTests.cs @@ -136,4 +136,63 @@ public class CSharpScriptExecutorTests Assert.False(result.Success); Assert.Contains("no static entry method", result.Error); } + + [Fact] + public async Task Runs_program_with_dynamic_input() + { + // Frontend stubs generate `dynamic input` (compiles to `object`), but the + // executor used to only match `string` params — so a valid stub compiled + // yet was never found at invoke time. + using var dir = new TempDir(); + dir.Write("Main.cs", """ + public static class Program + { + public static object Main(dynamic input) + { + return new + { + ok = true, + input = input + }; + } + } + """); + + var result = await _executor.ExecuteAsync( + Invocation.For("csharp", "Main.cs", "{\"x\":1}", dir.Path), default); + + Assert.True(result.Success, result.Error); + using var output = JsonDocument.Parse(result.Output!); + Assert.True(output.RootElement.GetProperty("ok").GetBoolean()); + Assert.Equal("{\"x\":1}", output.RootElement.GetProperty("input").GetString()); + } + + [Fact] + public async Task Reports_expression_body_followed_by_block_as_compile_error() + { + // Regression: an LLM/agent edit turned the stub body into `=> { ... }`, + // which is invalid C#. This is exactly the reported failure + // "Main.cs(5,48): error CS1525: Invalid expression term '{'". + using var dir = new TempDir(); + dir.Write("Main.cs", """ + public static class Program + { + public static object Main(dynamic input) => + { + return new + { + ok = true, + input = input + }; + } + } + """); + + var result = await _executor.ExecuteAsync( + Invocation.For("csharp", "Main.cs", "{}", dir.Path), default); + + Assert.False(result.Success); + Assert.Contains("compilation failed", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.Contains("CS1525", result.Error, StringComparison.OrdinalIgnoreCase); + } } diff --git a/w4c-workflows-api.Tests/TaskDispatcherWorkingDirTests.cs b/w4c-workflows-api.Tests/TaskDispatcherWorkingDirTests.cs new file mode 100644 index 0000000..74f8d22 --- /dev/null +++ b/w4c-workflows-api.Tests/TaskDispatcherWorkingDirTests.cs @@ -0,0 +1,87 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using w4c_workflows.Services; +using w4c_workflows.Services.Runs; +using Xunit; + +namespace w4c_workflows.Tests; + +/// +/// Regression: the run's working directory must be resolved from the tenant's OWN +/// source directory (the same one the workflow-file writer uses), never the +/// global/git root. Before the fix, fell back to the +/// git root when not in Forgejo-backed mode, so a freshly created workflow's entry +/// file (e.g. Main.cs) was not found at run time ("entry file not found: +/// Main.cs"). +/// +public class TaskDispatcherWorkingDirTests +{ + private static WorkflowSourceFactory NewFactory(string copiesRoot) => + new( + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["WorkflowSource:CopiesRoot"] = copiesRoot, + }) + .Build(), + env: null!, // unused by the factory, keep the test free of ASP.NET types + NullLoggerFactory.Instance); + + private static TaskDispatcher NewDispatcher(string copiesRoot) => + new( + db: null!, // not used by ResolveWorkingDir + jobs: null!, // not used by ResolveWorkingDir + config: new ConfigurationBuilder().Build(), + logger: NullLogger.Instance, + sourceFactory: NewFactory(copiesRoot)); + + [Fact] + public async Task ResolveWorkingDir_uses_tenant_copies_root_not_git_root() + { + using var tmp = new TempDir(); + var dispatcher = NewDispatcher(tmp.Path); + + // A workflow definition stored under the tenant's own dir, referenced as a + // repo-relative path with the shared "workflows" prefix. + var workingDir = dispatcher.ResolveWorkingDir("tenant-1", "workflows/test/workflow.yaml"); + + // The run must resolve inside the tenant's CopiesRoot directory, i.e. exactly + // where WorkflowFilesController.SaveFile writes the code files. + var expected = Path.Combine(tmp.Path, "tenant-1", "workflows", "test"); + Assert.Equal(Path.GetFullPath(expected), Path.GetFullPath(workingDir)); + + // And the entry file must actually exist there — proving "entry file not + // found: Main.cs" can no longer happen for a correctly scaffolded workflow. + Directory.CreateDirectory(expected); + var entry = Path.Combine(expected, "Main.cs"); + await File.WriteAllTextAsync(entry, "public static class Program { }"); + Assert.True(File.Exists(entry)); + } + + [Fact] + public async Task ResolveWorkingDirAsync_uses_tenant_copies_root_not_git_root() + { + using var tmp = new TempDir(); + var dispatcher = NewDispatcher(tmp.Path); + + var workingDir = await dispatcher.ResolveWorkingDirAsync( + "tenant-2", "workflows/ship/workflow.yaml", default); + + var expected = Path.Combine(tmp.Path, "tenant-2", "workflows", "ship"); + Assert.Equal(Path.GetFullPath(expected), Path.GetFullPath(workingDir)); + } + + [Fact] + public void ResolveWorkingDir_without_factory_falls_back_to_global_root() + { + var dispatcher = new TaskDispatcher( + db: null!, + jobs: null!, + config: new ConfigurationBuilder().Build(), + logger: NullLogger.Instance); + + // No source factory configured: must not throw, returns a deterministic path. + var workingDir = dispatcher.ResolveWorkingDir("t-1", "workflows/a/workflow.yaml"); + Assert.False(string.IsNullOrWhiteSpace(workingDir)); + } +} diff --git a/w4c-workflows-api.Tests/WorkflowCompilerTests.cs b/w4c-workflows-api.Tests/WorkflowCompilerTests.cs index 720bab1..1a1256b 100644 --- a/w4c-workflows-api.Tests/WorkflowCompilerTests.cs +++ b/w4c-workflows-api.Tests/WorkflowCompilerTests.cs @@ -45,7 +45,7 @@ public class WorkflowCompilerTests private static WorkflowCompiler NewCompiler() => new(new WorkflowValidator(new LanguageRegistry())); - private static Guid TaskId(string id) => DeterministicGuid.For($"task::{Tenant}::{Path}::{id}"); + private static Guid TaskId(string id) => DeterministicGuid.For($"task::{Tenant}::workflows::{Path}::{id}"); [Fact] public void Compiles_plan_example_with_correct_flow() diff --git a/w4c-workflows-api.Tests/WorkflowSyncIntegrationTests.cs b/w4c-workflows-api.Tests/WorkflowSyncIntegrationTests.cs index 2e2306a..51ab913 100644 --- a/w4c-workflows-api.Tests/WorkflowSyncIntegrationTests.cs +++ b/w4c-workflows-api.Tests/WorkflowSyncIntegrationTests.cs @@ -59,7 +59,7 @@ public class WorkflowSyncIntegrationTests var tenantId = "t" + Guid.NewGuid().ToString("N")[..12]; var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml }; - var result = await NewSync(tenantId, source).SyncAsync(tenantId, default); + var result = await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default); Assert.Equal(1, result.Compiled); Assert.Empty(result.Errors); @@ -82,7 +82,7 @@ public class WorkflowSyncIntegrationTests { var tenantId = "t" + Guid.NewGuid().ToString("N")[..12]; var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml }; - var sync = () => NewSync(tenantId, source).SyncAsync(tenantId, default); + var sync = () => NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default); await sync(); var second = await sync(); @@ -113,7 +113,7 @@ public class WorkflowSyncIntegrationTests """, }; - await NewSync(tenantId, source).SyncAsync(tenantId, default); + await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default); // Add a third task to the chain. source["workflows/main-task.yaml"] = """ @@ -132,7 +132,7 @@ public class WorkflowSyncIntegrationTests entry: { file: c.sh } """; - await NewSync(tenantId, source).SyncAsync(tenantId, default); + await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default); await using var db = _fixture.CreateContext(); var workflow = await db.Workflows.Include(w => w.Tasks).SingleAsync(w => w.TenantId == tenantId); @@ -146,10 +146,10 @@ public class WorkflowSyncIntegrationTests var tenantId = "t" + Guid.NewGuid().ToString("N")[..12]; var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml }; - await NewSync(tenantId, source).SyncAsync(tenantId, default); + await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default); source.Clear(); - var result = await NewSync(tenantId, source).SyncAsync(tenantId, default); + var result = await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default); Assert.Equal(1, result.Removed); await using var db = _fixture.CreateContext(); @@ -162,7 +162,7 @@ public class WorkflowSyncIntegrationTests var tenantId = "t" + Guid.NewGuid().ToString("N")[..12]; var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml }; - await NewSync(tenantId, source).SyncAsync(tenantId, default); + await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default); // Give the 'enrich' task a historical run before it is removed from the YAML. Guid enrichId; @@ -217,7 +217,7 @@ public class WorkflowSyncIntegrationTests entry: { file: compensate.py } """; - await NewSync(tenantId, source).SyncAsync(tenantId, default); + await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default); await using var after = _fixture.CreateContext(); // The historical TaskRun survives — sync no longer wipes it. @@ -238,7 +238,7 @@ public class WorkflowSyncIntegrationTests ["workflows/broken.yaml"] = "name: broken\nmode: function\nlanguage: cobol\nentry: { file: x.sh }", }; - var result = await NewSync(tenantId, source).SyncAsync(tenantId, default); + var result = await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default); Assert.Equal(0, result.Compiled); Assert.Single(result.Errors); diff --git a/w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs b/w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs index 523aba5..65134e0 100644 --- a/w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs +++ b/w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs @@ -41,6 +41,13 @@ public sealed class WorkflowsPostgresFixture : IAsyncLifetime "ALTER TABLE workflows.\"Tasks\" ADD COLUMN IF NOT EXISTS \"ArchivedAt\" timestamptz NULL;"); await context.Database.ExecuteSqlRawAsync( "ALTER TABLE workflows.\"Tasks\" ADD COLUMN IF NOT EXISTS \"Server\" text NULL;"); + // Workflow repo scoping: the repo name column + per-tenant repo setting table. + await context.Database.ExecuteSqlRawAsync( + "ALTER TABLE workflows.\"Workflows\" ADD COLUMN IF NOT EXISTS \"Repo\" text NOT NULL DEFAULT 'workflows';"); + await context.Database.ExecuteSqlRawAsync( + "CREATE TABLE IF NOT EXISTS workflows.\"WorkflowRepos\" (" + + "\"TenantId\" text NOT NULL, \"RepoName\" text NOT NULL, \"UpdatedAt\" timestamptz NULL, " + + "CONSTRAINT \"PK_WorkflowRepos\" PRIMARY KEY (\"TenantId\"));"); } public async Task DisposeAsync()