workflow source mapping

This commit is contained in:
Vitali sharp8n 2026-09-03 17:44:39 +03:00
parent 1f34453886
commit 4ad6cc2cb9
18 changed files with 499 additions and 79 deletions

View file

@ -28,17 +28,23 @@ namespace w4c_workflows.Controllers;
[Route("api/workflow-files")]
public class WorkflowFilesController : ControllerBase
{
private readonly WorkflowSourceFactory _sourceFactory;
private readonly ILogger<WorkflowFilesController> _logger;
public WorkflowFilesController(
WorkflowSourceFactory sourceFactory,
WorkflowRepoStore repoStore,
WorkflowSyncService sync,
ILogger<WorkflowFilesController> logger)
{
_sourceFactory = sourceFactory;
_repoStore = repoStore;
_sync = sync;
_logger = logger;
}
private readonly WorkflowSourceFactory _sourceFactory;
private readonly WorkflowRepoStore _repoStore;
private readonly WorkflowSyncService _sync;
private readonly ILogger<WorkflowFilesController> _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.
/// </summary>
private string SourceDir => _sourceFactory.ResolveTenantSourceDir(TenantId, MaybeForgejoLogin);
private string SourceDir => _sourceFactory.ResolveTenantSourceDir(
TenantId, MaybeForgejoLogin, CurrentRepo);
/// <summary>The tenant's current workflow repo name (set by the source middleware).</summary>
private string CurrentRepo =>
(HttpContext.Items["WorkflowRepo"] as string)
?? (_sourceFactory.Forgejo?.WorkflowRepoName ?? "workflows");
/// <summary>Returns the Forgejo repo info for this tenant's workflow files.</summary>
[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
});
}
/// <summary>
/// Sets the tenant's workflow repository (basename, e.g. <c>workflows</c> or
/// a user-selected repo) and re-syncs. Switching repos unloads workflows
/// compiled from any previously-selected repo (they are no longer listed).
/// </summary>
[HttpPut("repo")]
[RequireScope("manage")]
public async Task<IActionResult> 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,
});
}
/// <summary>
/// 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<object>(), untracked = Array.Empty<object>() });
@ -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);

View file

@ -307,7 +307,8 @@ public class WorkflowsController : ControllerBase
[RequireScope("manage")]
public async Task<IActionResult> 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);

View file

@ -10,6 +10,16 @@ public class WorkflowsDbContext : DbContext
{
}
/// <summary>
/// The tenant's current workflow repo name (set per request by the source
/// middleware, e.g. <c>workflows</c> or a user-selected repo). Every
/// <see cref="Workflow"/> 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.
/// </summary>
public string? CurrentRepo { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
@ -28,6 +38,7 @@ public class WorkflowsDbContext : DbContext
public DbSet<TaskRun> TaskRuns => Set<TaskRun>();
public DbSet<DurableState> DurableStates => Set<DurableState>();
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
public DbSet<TenantWorkflowRepo> WorkflowRepos => Set<TenantWorkflowRepo>();
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<WorkflowTask>(e =>
@ -129,5 +147,12 @@ public class WorkflowsDbContext : DbContext
e.Property(k => k.ScopesJson).HasColumnType("jsonb");
e.HasIndex(k => k.TenantId);
});
modelBuilder.Entity<TenantWorkflowRepo>(e =>
{
e.HasKey(r => r.TenantId);
e.Property(r => r.TenantId).HasMaxLength(120).IsRequired();
e.Property(r => r.RepoName).HasMaxLength(120).IsRequired();
});
}
}

View file

@ -68,6 +68,14 @@ public class Workflow
public required string Name { get; set; }
public required string Path { get; set; }
public string? GitSha { get; set; }
/// <summary>
/// The repository name (basename) this workflow was compiled from, e.g.
/// <c>workflows</c> (default) or a user-selected repo like <c>wiz4apps</c>.
/// The workflows module only lists / runs workflows whose <see cref="Repo"/>
/// matches the tenant's current workflow repo; switching repos "unloads"
/// workflows from other repos without deleting their history.
/// </summary>
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; }
}
/// <summary>
/// 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 <c>WorkflowSource:WorkflowRepoName</c>
/// ("workflows") when no row exists.
/// </summary>
public class TenantWorkflowRepo
{
public required string TenantId { get; set; }
public required string RepoName { get; set; }
public DateTime? UpdatedAt { get; set; }
}
/// <summary>
/// Per-tenant operator API key. Only the SHA-256 hash is stored; the raw key is
/// shown once at mint time.

View file

@ -126,6 +126,7 @@ builder.Services.AddSingleton<WorkflowCompiler>();
// 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<WorkflowSourceFactory>();
builder.Services.AddScoped<WorkflowRepoStore>();
builder.Services.AddScoped<IWorkflowSource>(sp =>
{
var http = sp.GetRequiredService<IHttpContextAccessor>();
@ -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<IConfiguration>()["WorkflowSource:WorkflowRepoName"] ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(repoName)) repoName = "workflows";
try
{
var store = context.RequestServices.GetRequiredService<WorkflowRepoStore>();
repoName = await store.GetNameAsync(tenantId, context.RequestAborted);
}
catch (Exception ex)
{
var l = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("WorkflowSourceMiddleware");
l.LogDebug(ex, "Could not resolve workflow repo for tenant {TenantId}", tenantId);
}
var db = context.RequestServices.GetRequiredService<WorkflowsDbContext>();
db.CurrentRepo = repoName;
context.Items["WorkflowRepo"] = repoName;
var factory = context.RequestServices.GetRequiredService<WorkflowSourceFactory>();
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)

View file

@ -25,8 +25,9 @@ namespace w4c_workflows.Services.Execution;
///
/// Entry contract (v1): a <c>public static</c> method named after
/// <c>entry.function</c> (default <c>Main</c>) that takes a single
/// <c>string</c> (the JSON input) or no arguments, returning a value serialized
/// to JSON as the task output (sync or <c>Task</c>/<c>Task&lt;T&gt;</c>).
/// <c>string</c> or <c>object</c>/<c>dynamic</c> argument (the JSON input) or
/// no arguments, returning a value serialized to JSON as the task output (sync
/// or <c>Task</c>/<c>Task&lt;T&gt;</c>).
/// </summary>
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<MetadataReference>();
var seen = new HashSet<string>(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;

View file

@ -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;
}
/// <summary>The Forgejo base URL (for building repo links).</summary>
@ -68,33 +73,46 @@ public sealed class ForgejoWorkflowRepoService
!string.IsNullOrWhiteSpace(_forgejoOwner);
/// <summary>The repo full name on Forgejo (owner/repo-name).</summary>
public string RepoFullName(string tenantId) =>
$"{_forgejoOwner.ToLowerInvariant()}/{RepoName(tenantId)}";
/// <summary>The sanitized repo name for a tenant.</summary>
public static string RepoName(string tenantId) =>
$"workflows-{Sanitize(tenantId)}";
/// <summary>
/// The repository name (basename) used for a tenant's workflow repo. Default
/// is <c>workflows</c> (the repo full name <c>{login}/workflows</c>), so a
/// tenant's workflows live in a repo whose name contains "workflow". A tenant
/// may select a different repo (e.g. <c>wiz4apps</c>) to share the directory
/// with the main-api Source Code explorer; the selection is persisted per
/// tenant and overrides this default.
/// </summary>
public string WorkflowRepoName { get; }
/// <summary>
/// Resolves the Forgejo repo full name for a user. The repo lives under
/// the user's own Forgejo account: <c>{login}/workflows-{login}</c>.
/// 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 (<c>{owner}_{repo}</c>). Deriving full name AND
/// slug from a single repo name is what keeps the two services pointing at the
/// same directory.
/// </summary>
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}");
}
/// <summary>
/// Resolves the Forgejo repo full name for a user given the configured/selected
/// workflow repo name: <c>{login}/{repoName}</c>.
/// </summary>
public string RepoFullNameForLogin(string login, string? repoName = null) =>
ResolveWorkflowRepo(login, repoName ?? WorkflowRepoName).RepoFullName;
/// <summary>
/// The source-copies owner-repo slug for a user's workflow repo, matching the
/// webapi SourceCodeCopyService layout (<c>{owner}_{repo}</c>, e.g.
/// <c>test_workflows-test</c>). The workflow repo is <c>{login}/workflows-{login}</c>,
/// so the slug is <c>{login}_workflows-{login}</c>. Sharing this exact directory
/// with the webapi source-code explorer is what keeps workflow edits visible in
/// the source-code view without a manual fetch/pull.
/// <c>test_workflows</c>). Sharing this exact directory with the webapi
/// source-code explorer is what keeps workflow edits visible in the source-code
/// view without a manual fetch/pull.
/// </summary>
public static string OwnerRepoSlug(string login)
{
var owner = login.ToLowerInvariant();
return $"{Sanitize(owner)}_workflows-{Sanitize(owner)}";
}
public string OwnerRepoSlug(string login, string? repoName = null) =>
ResolveWorkflowRepo(login, repoName ?? WorkflowRepoName).RepoSlug;
/// <summary>
/// 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.
/// </summary>
public async Task<string> EnsureCloneAsync(string tenantId, string login, CancellationToken ct = default)
public async Task<string> 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.
/// </summary>
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<string?> RunGitAsync(string workDir, CancellationToken ct, params string[] args)
{

View file

@ -94,10 +94,10 @@ public sealed class WorkflowSourceFactory
/// Used by the workflow-file CRUD controller so edits always land in the same
/// place <see cref="IWorkflowSource"/> reads from.
/// </summary>
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 <see cref="Create"/>.
/// </summary>
public async Task<IWorkflowSource> CreateAsync(string tenantId, CancellationToken ct = default)
public async Task<IWorkflowSource> 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<WorkflowFile> ListYamlFiles(string dir)

View file

@ -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<string> ResolveWorkerRootForTenantAsync(string tenantId, CancellationToken ct)
{
// In Forgejo-backed mode, resolve from the tenant's shared local clone,
// whose path depends on the user's Forgejo login (resolved via the factory).
if (_sourceFactory?.IsForgejoBacked == true && _sourceFactory.Forgejo != null)
{
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)

View file

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

View file

@ -0,0 +1,76 @@
using Microsoft.EntityFrameworkCore;
using w4c_workflows.Data;
using w4c_workflows.Models;
namespace w4c_workflows.Services;
/// <summary>
/// Reads and updates the tenant's selected workflow repository name. The repo name
/// is a basename (e.g. <c>workflows</c> by default, or a user-selected repo like
/// <c>wiz4apps</c>). The full repo is <c>{login}/{repoName}</c>; its local clone dir
/// is <c>{CopiesRoot}/{tenantId}/{login}_{repoName}</c> (see
/// <see cref="ForgejoWorkflowRepoService.ResolveWorkflowRepo"/>).
///
/// The selection is durable per tenant (table <c>WorkflowRepos</c>) and falls back
/// to <c>WorkflowSource:WorkflowRepoName</c> (default <c>workflows</c>) when no row
/// exists — so a fresh tenant automatically uses the repository named "workflow".
/// </summary>
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;
}
/// <summary>The configured default repo name (used when no per-tenant row exists).</summary>
public string DefaultName => _defaultName;
/// <summary>Resolves the tenant's current workflow repo name (stored or default).</summary>
public async Task<string> 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;
}
/// <summary>
/// Persists the tenant's workflow repo name. Returns the normalized name (trimmed,
/// no leading/trailing slashes).
/// </summary>
public async Task<string> 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;
}
/// <summary>Validates a repo basename (letters/digits/._-), returns normalized or null.</summary>
public static string NormalizeName(string? repoName)
{
var clean = (repoName ?? string.Empty).Trim().Trim('/');
return string.IsNullOrWhiteSpace(clean) ? "workflows" : clean;
}
}

View file

@ -47,7 +47,7 @@ public class WorkflowSyncService
_sourceFactory = sourceFactory;
}
public async Task<SyncResult> SyncAsync(string tenantId, CancellationToken ct)
public async Task<SyncResult> 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 <c>Repo</c> 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));

View file

@ -69,6 +69,7 @@
},
"WorkflowSource": {
"CopiesRoot": "../source-copies",
"SharedDir": "workflows"
"SharedDir": "workflows",
"WorkflowRepoName": "workflows"
}
}

View file

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

View file

@ -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;
/// <summary>
/// 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, <see cref="TaskDispatcher"/> fell back to the
/// git root when not in Forgejo-backed mode, so a freshly created workflow's entry
/// file (e.g. <c>Main.cs</c>) was not found at run time ("entry file not found:
/// Main.cs").
/// </summary>
public class TaskDispatcherWorkingDirTests
{
private static WorkflowSourceFactory NewFactory(string copiesRoot) =>
new(
new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<TaskDispatcher>.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<TaskDispatcher>.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));
}
}

View file

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

View file

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

View file

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