workflow source mapping
This commit is contained in:
parent
1f34453886
commit
4ad6cc2cb9
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
29
Program.cs
29
Program.cs
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<T></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<T></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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
76
Services/WorkflowRepoStore.cs
Normal file
76
Services/WorkflowRepoStore.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@
|
|||
},
|
||||
"WorkflowSource": {
|
||||
"CopiesRoot": "../source-copies",
|
||||
"SharedDir": "workflows"
|
||||
"SharedDir": "workflows",
|
||||
"WorkflowRepoName": "workflows"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
87
w4c-workflows-api.Tests/TaskDispatcherWorkingDirTests.cs
Normal file
87
w4c-workflows-api.Tests/TaskDispatcherWorkingDirTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue