w4c-workflows-api/Controllers/WorkflowFilesController.cs

412 lines
17 KiB
C#
Raw Normal View History

2026-09-01 22:12:21 +00:00
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using w4c_workflows.Filters;
using w4c_workflows.Services;
namespace w4c_workflows.Controllers;
/// <summary>
/// Workflow file management endpoints. Provides CRUD operations for the
/// workflow YAML definitions and their sibling code files (shell scripts,
2026-09-02 19:14:59 +00:00
/// JS, Python, etc.) that live in the tenant's per-tenant workflow repository.
2026-09-01 22:12:21 +00:00
///
2026-09-02 19:14:59 +00:00
/// The repository is the single source of truth for a tenant's workflow content:
/// the workflows module edits files here and <c>POST /api/workflows/sync</c>
/// recompiles them (see <see cref="WorkflowSyncService"/>). Edits land in the
/// SAME directory <see cref="IWorkflowSource"/> reads from, so the "edit in the
/// workflows module" and "the engine runs it" paths are always coherent.
///
/// Works in both storage modes:
/// - <b>Forgejo-backed</b> (Forgejo:AdminToken set): files live in a private
/// per-user Forgejo repo <c>{login}/workflows-{login}</c>, cloned locally.
/// <c>/commit</c> and <c>/pull</c> sync to/from the remote.
/// - <b>Filesystem-only</b> (no AdminToken): files live under
/// <c>{CopiesRoot}/{tenantId}/</c> with no remote. <c>/commit</c> and
/// <c>/pull</c> are no-ops.
2026-09-01 22:12:21 +00:00
/// </summary>
[ApiController]
[Route("api/workflow-files")]
public class WorkflowFilesController : ControllerBase
{
public WorkflowFilesController(
WorkflowSourceFactory sourceFactory,
2026-09-03 14:44:39 +00:00
WorkflowRepoStore repoStore,
WorkflowSyncService sync,
2026-09-07 19:05:15 +00:00
RealtimeEventHub events,
2026-09-01 22:12:21 +00:00
ILogger<WorkflowFilesController> logger)
{
_sourceFactory = sourceFactory;
2026-09-03 14:44:39 +00:00
_repoStore = repoStore;
_sync = sync;
2026-09-07 19:05:15 +00:00
_events = events;
2026-09-01 22:12:21 +00:00
_logger = logger;
}
2026-09-03 14:44:39 +00:00
private readonly WorkflowSourceFactory _sourceFactory;
private readonly WorkflowRepoStore _repoStore;
private readonly WorkflowSyncService _sync;
2026-09-07 19:05:15 +00:00
private readonly RealtimeEventHub _events;
2026-09-03 14:44:39 +00:00
private readonly ILogger<WorkflowFilesController> _logger;
2026-09-01 22:12:21 +00:00
private string TenantId => (string?)HttpContext.Items["TenantId"]
?? throw new InvalidOperationException("TenantId not resolved by auth middleware");
/// <summary>
2026-09-02 19:14:59 +00:00
/// The user's Forgejo login when the middleware resolved it (Forgejo-backed
/// mode succeeded). Null when in filesystem-only mode OR when the Forgejo
/// login could not be resolved (the request then falls back to the filesystem
/// source, so file CRUD must target the filesystem dir to stay consistent).
2026-09-01 22:12:21 +00:00
/// </summary>
2026-09-02 19:14:59 +00:00
private string? MaybeForgejoLogin =>
HttpContext.Items.TryGetValue("ForgejoLogin", out var v) ? v as string : null;
/// <summary>
/// The on-disk directory holding the tenant's workflow files. In Forgejo-backed
/// mode (login resolved) it is the per-user clone dir; otherwise it is the
/// per-tenant directory. Both are exactly the root <see cref="IWorkflowSource"/>
/// reads from, so file edits made through these endpoints are what get compiled
/// on sync.
/// </summary>
2026-09-03 14:44:39 +00:00
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");
2026-09-01 22:12:21 +00:00
/// <summary>Returns the Forgejo repo info for this tenant's workflow files.</summary>
[HttpGet("repo")]
[RequireScope("read")]
public IActionResult GetRepoInfo()
{
if (_sourceFactory.Forgejo == null)
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
2026-09-02 19:14:59 +00:00
var login = MaybeForgejoLogin;
if (string.IsNullOrWhiteSpace(login))
return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
2026-09-01 22:12:21 +00:00
2026-09-03 14:44:39 +00:00
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);
2026-09-01 22:12:21 +00:00
var exists = Directory.Exists(Path.Combine(cloneDir, ".git"));
return Ok(new
{
2026-09-03 14:44:39 +00:00
repoName,
2026-09-01 22:12:21 +00:00
repoFullName = fullName,
cloneDir,
cloned = exists,
2026-09-02 19:14:59 +00:00
forgejoUrl = $"{_sourceFactory.Forgejo.ForgejoBaseUrl}/{fullName}",
2026-09-01 22:12:21 +00:00
});
}
2026-09-03 14:44:39 +00:00
/// <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,
});
}
2026-09-01 22:12:21 +00:00
/// <summary>
2026-09-02 19:14:59 +00:00
/// Lists all files under a directory (recursive, excluding .git), plus the
/// top-level subdirectories. Returns repo-relative paths.
2026-09-01 22:12:21 +00:00
/// </summary>
[HttpGet]
[RequireScope("read")]
public IActionResult ListFiles([FromQuery] string? path = null)
{
2026-09-02 19:14:59 +00:00
var sourceDir = SourceDir;
if (!Directory.Exists(sourceDir))
2026-09-01 22:12:21 +00:00
return Ok(new { files = Array.Empty<object>() });
var dir = string.IsNullOrWhiteSpace(path)
2026-09-02 19:14:59 +00:00
? sourceDir
: Path.Combine(sourceDir, path.Replace('/', Path.DirectorySeparatorChar));
2026-09-01 22:12:21 +00:00
2026-09-02 19:14:59 +00:00
var fullSourceDir = Path.GetFullPath(sourceDir);
var fullDir = Path.GetFullPath(dir);
if (!fullDir.StartsWith(fullSourceDir, StringComparison.Ordinal))
return BadRequest(new { error = "Invalid path" });
if (!Directory.Exists(fullDir))
2026-09-01 22:12:21 +00:00
return NotFound(new { error = $"Directory not found: {path}" });
var files = new List<object>();
2026-09-02 19:14:59 +00:00
foreach (var full in Directory.EnumerateFiles(fullDir, "*", SearchOption.AllDirectories))
2026-09-01 22:12:21 +00:00
{
2026-09-02 19:14:59 +00:00
var rel = Path.GetRelativePath(sourceDir, full).Replace(Path.DirectorySeparatorChar, '/');
if (rel.StartsWith(".git", StringComparison.OrdinalIgnoreCase))
continue;
2026-09-01 22:12:21 +00:00
var info = new FileInfo(full);
2026-09-02 19:14:59 +00:00
files.Add(new { path = rel, name = Path.GetFileName(full), size = info.Length, modified = info.LastWriteTimeUtc });
2026-09-01 22:12:21 +00:00
}
2026-09-02 19:14:59 +00:00
foreach (var full in Directory.EnumerateDirectories(fullDir))
2026-09-01 22:12:21 +00:00
{
var name = Path.GetFileName(full);
if (name == ".git") continue;
2026-09-02 19:14:59 +00:00
var rel = Path.GetRelativePath(sourceDir, full).Replace(Path.DirectorySeparatorChar, '/');
files.Add(new { path = rel, name, isDir = true });
2026-09-01 22:12:21 +00:00
}
return Ok(new { files });
}
/// <summary>Returns the content of a specific file.</summary>
[HttpGet("content")]
[RequireScope("read")]
public IActionResult GetContent([FromQuery] string path)
{
if (string.IsNullOrWhiteSpace(path))
return BadRequest(new { error = "path is required" });
2026-09-02 19:14:59 +00:00
var full = ResolveSafeFile(path, out var status);
if (status.HasValue)
2026-09-01 22:12:21 +00:00
return BadRequest(new { error = "Invalid path" });
if (!System.IO.File.Exists(full))
return NotFound(new { error = $"File not found: {path}" });
var content = System.IO.File.ReadAllText(full);
var info = new FileInfo(full);
return Ok(new { path, content, size = info.Length, modified = info.LastWriteTimeUtc });
}
/// <summary>
2026-09-02 19:14:59 +00:00
/// Saves content to a file in the tenant's workflow repo. Writes to the local
/// working tree (no commit/push — use POST /commit in Forgejo mode for that).
2026-09-01 22:12:21 +00:00
/// </summary>
[HttpPost("save")]
[RequireScope("manage")]
public IActionResult SaveFile([FromBody] SaveWorkflowFileRequest request)
{
if (string.IsNullOrWhiteSpace(request.Path))
return BadRequest(new { error = "path is required" });
2026-09-02 19:14:59 +00:00
var full = ResolveSafeFile(request.Path, out var status);
if (status.HasValue)
2026-09-01 22:12:21 +00:00
return BadRequest(new { error = "Invalid path" });
try
{
var dir = Path.GetDirectoryName(full);
if (dir != null && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
System.IO.File.WriteAllText(full, request.Content ?? string.Empty, new System.Text.UTF8Encoding(false));
2026-09-02 19:14:59 +00:00
_logger.LogInformation("Saved workflow file {Path} for tenant {TenantId} ({Login})",
request.Path, TenantId, MaybeForgejoLogin ?? "-");
2026-09-07 19:05:15 +00:00
// Notify any open workflow editor that the file changed server-side (frontend is optional:
// the AI can write it via the backend while no page is open, then the page reloads on next visit).
_events.Publish(TenantId, "workflow", request.Path, DateTime.UtcNow);
2026-09-01 22:12:21 +00:00
return Ok(new { success = true, path = request.Path });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save workflow file {Path}", request.Path);
return BadRequest(new { error = ex.Message });
}
}
2026-09-02 19:14:59 +00:00
/// <summary>
/// Deletes files/directories from the tenant's workflow repo. Used to remove a
/// whole workflow (its folder) or an individual file. Returns the paths deleted.
/// </summary>
[HttpPost("delete")]
[RequireScope("manage")]
public IActionResult DeleteFiles([FromBody] DeleteWorkflowFileRequest? request)
{
if (request == null || request.Paths == null || request.Paths.Length == 0)
return BadRequest(new { error = "paths is required" });
var sourceDir = SourceDir;
var fullSourceDir = Path.GetFullPath(sourceDir);
var deleted = new List<string>();
foreach (var raw in request.Paths)
{
if (string.IsNullOrWhiteSpace(raw)) continue;
var full = Path.GetFullPath(Path.Combine(sourceDir, raw.Replace('/', Path.DirectorySeparatorChar)));
if (!full.StartsWith(fullSourceDir, StringComparison.Ordinal) ||
full == fullSourceDir)
continue;
if (Directory.Exists(full))
Directory.Delete(full, recursive: true);
else if (System.IO.File.Exists(full))
System.IO.File.Delete(full);
else
continue;
deleted.Add(raw);
}
_logger.LogInformation("Deleted workflow files for tenant {TenantId}: {Paths}",
TenantId, string.Join(", ", deleted));
return Ok(new { success = true, deleted });
}
/// <summary>Returns the git status of the tenant's workflow repo (Forgejo-backed mode only).</summary>
2026-09-01 22:12:21 +00:00
[HttpGet("status")]
[RequireScope("read")]
public IActionResult GetStatus()
{
if (_sourceFactory.Forgejo == null)
return StatusCode(503, new { error = "Forgejo-backed workflow mode is not configured." });
2026-09-02 19:14:59 +00:00
var login = MaybeForgejoLogin;
if (string.IsNullOrWhiteSpace(login))
return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
2026-09-01 22:12:21 +00:00
2026-09-03 14:44:39 +00:00
var cloneDir = _sourceFactory.Forgejo.TenantCloneDir(TenantId, login, CurrentRepo);
2026-09-01 22:12:21 +00:00
if (!Directory.Exists(Path.Combine(cloneDir, ".git")))
return Ok(new { branch = "", changed = Array.Empty<object>(), untracked = Array.Empty<object>() });
var branch = RunGit(cloneDir, "rev-parse", "--abbrev-ref", "HEAD")?.Trim() ?? "";
var statusOutput = RunGit(cloneDir, "status", "--porcelain") ?? "";
var changed = new List<object>();
var untracked = new List<object>();
foreach (var line in statusOutput.Split('\n', StringSplitOptions.RemoveEmptyEntries))
{
if (line.Length < 4) continue;
var statusCode = line[..2].Trim();
var filePath = line[3..].Trim();
if (statusCode == "??")
untracked.Add(new { path = filePath, status = "untracked" });
else
changed.Add(new { path = filePath, status = statusCode });
}
var head = RunGit(cloneDir, "rev-parse", "--short", "HEAD")?.Trim() ?? "";
return Ok(new { branch, head, changed, untracked });
}
/// <summary>
2026-09-02 19:14:59 +00:00
/// Commits and pushes all changes to Forgejo. A no-op (returns success) in
/// filesystem-only mode. After committing, call POST /api/workflows/sync to
/// recompile.
2026-09-01 22:12:21 +00:00
/// </summary>
[HttpPost("commit")]
[RequireScope("manage")]
public async Task<IActionResult> CommitAndPush([FromBody] CommitWorkflowRequest? request = null)
{
if (_sourceFactory.Forgejo == null)
2026-09-02 19:14:59 +00:00
return Ok(new { success = true, output = "filesystem mode — no remote to push" });
var login = MaybeForgejoLogin;
if (string.IsNullOrWhiteSpace(login))
return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
2026-09-01 22:12:21 +00:00
var message = string.IsNullOrWhiteSpace(request?.Message)
? $"Update workflow files ({DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC)"
: request!.Message;
var (success, output) = await _sourceFactory.Forgejo.CommitAndPushAsync(
2026-09-03 07:59:54 +00:00
TenantId, login, message, HttpContext.RequestAborted);
2026-09-01 22:12:21 +00:00
_logger.LogInformation("Commit/push for login {Login}: {Success} ({Output})",
2026-09-02 19:14:59 +00:00
login, success, output?.Trim() ?? "");
2026-09-01 22:12:21 +00:00
return Ok(new { success, output });
}
2026-09-02 19:14:59 +00:00
/// <summary>Pulls the latest changes from Forgejo (no-op in filesystem-only mode).</summary>
2026-09-01 22:12:21 +00:00
[HttpPost("pull")]
[RequireScope("manage")]
public async Task<IActionResult> Pull()
{
if (_sourceFactory.Forgejo == null)
2026-09-02 19:14:59 +00:00
return Ok(new { success = true, output = "filesystem mode — no remote to pull" });
var login = MaybeForgejoLogin;
if (string.IsNullOrWhiteSpace(login))
return StatusCode(503, new { error = "Forgejo login not resolved for this tenant." });
2026-09-01 22:12:21 +00:00
2026-09-03 07:59:54 +00:00
var ok = await _sourceFactory.Forgejo.PullAsync(TenantId, login, HttpContext.RequestAborted);
2026-09-01 22:12:21 +00:00
return Ok(new { success = ok });
}
2026-09-02 19:14:59 +00:00
/// <summary>Resolves a repo-relative path to an absolute path inside the tenant source dir.</summary>
private string ResolveSafeFile(string path, out int? status)
{
var sourceDir = SourceDir;
var full = Path.GetFullPath(Path.Combine(sourceDir, path.Replace('/', Path.DirectorySeparatorChar)));
var fullSourceDir = Path.GetFullPath(sourceDir);
if (!full.StartsWith(fullSourceDir, StringComparison.Ordinal))
{
status = 400;
return full;
}
status = null;
return full;
}
2026-09-01 22:12:21 +00:00
private string? RunGit(string workDir, params string[] args)
{
try
{
var psi = new ProcessStartInfo("git")
{
WorkingDirectory = workDir,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
foreach (var arg in args) psi.ArgumentList.Add(arg);
using var process = Process.Start(psi);
if (process == null) return null;
var output = process.StandardOutput.ReadToEnd();
if (!process.WaitForExit(5000))
{
process.Kill(entireProcessTree: true);
return null;
}
return process.ExitCode == 0 ? output.Trim() : null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "git invocation failed in {Dir}", workDir);
return null;
}
}
}
2026-09-03 14:44:39 +00:00
public sealed record SetWorkflowRepoRequest(string? RepoName);
2026-09-01 22:12:21 +00:00
public sealed record SaveWorkflowFileRequest(string Path, string? Content);
public sealed record CommitWorkflowRequest(string? Message);
2026-09-02 19:14:59 +00:00
public sealed record DeleteWorkflowFileRequest(string[] Paths);