workflows
This commit is contained in:
parent
87b1abae4e
commit
990a528d67
87
Controllers/RealtimeController.cs
Normal file
87
Controllers/RealtimeController.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using w4c_workflows.Services;
|
||||
|
||||
namespace w4c_workflows.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant-scoped server-sent-events stream: <c>GET /api/workflows/events</c> pushes
|
||||
/// <c>data: {"kind":"workflow","id":"<path>","at":"<iso>"}</c> frames whenever a workflow file is
|
||||
/// written server-side. The client re-fetches the touched file; no payload is pushed.
|
||||
/// The full literal route avoids colliding with <c>api/workflows/{id}</c> and routes to this service
|
||||
/// through the SPA proxy (<c>/api/workflows</c> prefix).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/workflows/events")]
|
||||
public class RealtimeController : ControllerBase
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(15);
|
||||
|
||||
private readonly RealtimeEventHub _hub;
|
||||
private readonly ILogger<RealtimeController> _logger;
|
||||
|
||||
public RealtimeController(RealtimeEventHub hub, ILogger<RealtimeController> logger)
|
||||
{
|
||||
_hub = hub;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string TenantId =>
|
||||
(string?)HttpContext.Items["TenantId"]
|
||||
?? throw new InvalidOperationException("TenantId not resolved by auth middleware");
|
||||
|
||||
[HttpGet]
|
||||
public async Task Stream(CancellationToken ct)
|
||||
{
|
||||
var tid = TenantId;
|
||||
|
||||
Response.Headers["Content-Type"] = "text/event-stream";
|
||||
Response.Headers["Cache-Control"] = "no-cache";
|
||||
Response.Headers["Connection"] = "keep-alive";
|
||||
Response.Headers["X-Accel-Buffering"] = "no";
|
||||
await Response.Body.FlushAsync(ct);
|
||||
|
||||
var (id, reader) = _hub.AddClient(tid);
|
||||
try
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
using var readCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
readCts.CancelAfter(HeartbeatInterval);
|
||||
bool hasItem;
|
||||
try
|
||||
{
|
||||
hasItem = await reader.WaitToReadAsync(readCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
await Response.WriteAsync(": ping\n\n", ct);
|
||||
await Response.Body.FlushAsync(ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasItem) break;
|
||||
|
||||
while (reader.TryRead(out var evt))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(evt, JsonOptions);
|
||||
await Response.WriteAsync($"data: {json}\n\n", ct);
|
||||
}
|
||||
await Response.Body.FlushAsync(ct);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Client disconnected — normal terminal state.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Realtime SSE stream ended for tenant {Tenant}", tid);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_hub.RemoveClient(tid, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,17 +32,20 @@ public class WorkflowFilesController : ControllerBase
|
|||
WorkflowSourceFactory sourceFactory,
|
||||
WorkflowRepoStore repoStore,
|
||||
WorkflowSyncService sync,
|
||||
RealtimeEventHub events,
|
||||
ILogger<WorkflowFilesController> logger)
|
||||
{
|
||||
_sourceFactory = sourceFactory;
|
||||
_repoStore = repoStore;
|
||||
_sync = sync;
|
||||
_events = events;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private readonly WorkflowSourceFactory _sourceFactory;
|
||||
private readonly WorkflowRepoStore _repoStore;
|
||||
private readonly WorkflowSyncService _sync;
|
||||
private readonly RealtimeEventHub _events;
|
||||
private readonly ILogger<WorkflowFilesController> _logger;
|
||||
|
||||
private string TenantId => (string?)HttpContext.Items["TenantId"]
|
||||
|
|
@ -225,6 +228,9 @@ public class WorkflowFilesController : ControllerBase
|
|||
System.IO.File.WriteAllText(full, request.Content ?? string.Empty, new System.Text.UTF8Encoding(false));
|
||||
_logger.LogInformation("Saved workflow file {Path} for tenant {TenantId} ({Login})",
|
||||
request.Path, TenantId, MaybeForgejoLogin ?? "-");
|
||||
// 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);
|
||||
return Ok(new { success = true, path = request.Path });
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
|||
|
|
@ -120,6 +120,9 @@ builder.Services.AddSingleton<LanguageRegistry>();
|
|||
builder.Services.AddSingleton<WorkflowValidator>();
|
||||
builder.Services.AddSingleton<WorkflowCompiler>();
|
||||
|
||||
// Realtime SSE hub — notifies subscribed clients when a workflow file changes server-side.
|
||||
builder.Services.AddSingleton<RealtimeEventHub>();
|
||||
|
||||
// Workflow source: factory-based — each request creates a tenant-scoped source.
|
||||
// WorkflowSource:CopiesRoot is REQUIRED; the factory throws if it is empty,
|
||||
// preventing any request from serving cross-tenant workflows.
|
||||
|
|
|
|||
47
Services/RealtimeEventHub.cs
Normal file
47
Services/RealtimeEventHub.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using System.Collections.Concurrent;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Server-push hub for tenant-scoped realtime events (SSE), mirroring w4c-webapi's hub. A connected
|
||||
/// client gets its own unbounded channel; <see cref="Publish"/> writes a tiny notification into every
|
||||
/// channel for the tenant. The client decides what to re-fetch, keeping broadcasts cheap.
|
||||
///
|
||||
/// Used to make the frontend an optional view: when a workflow file is written server-side (e.g. by
|
||||
/// the AI transform), every subscribed client in the tenant is notified and reloads the file.
|
||||
/// </summary>
|
||||
public sealed class RealtimeEventHub
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<Guid, Channel<RealtimeEvent>>> _clients = new();
|
||||
|
||||
public (Guid Id, ChannelReader<RealtimeEvent> Reader) AddClient(string tenantId)
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<RealtimeEvent>();
|
||||
var id = Guid.NewGuid();
|
||||
var clients = _clients.GetOrAdd(tenantId, _ => new ConcurrentDictionary<Guid, Channel<RealtimeEvent>>());
|
||||
clients[id] = channel;
|
||||
return (id, channel.Reader);
|
||||
}
|
||||
|
||||
public void RemoveClient(string tenantId, Guid id)
|
||||
{
|
||||
if (!_clients.TryGetValue(tenantId, out var clients)) return;
|
||||
clients.TryRemove(id, out _);
|
||||
if (clients.IsEmpty) _clients.TryRemove(tenantId, out _);
|
||||
}
|
||||
|
||||
public void Publish(string tenantId, string kind, string id, DateTime at)
|
||||
{
|
||||
if (!_clients.TryGetValue(tenantId, out var clients)) return;
|
||||
var evt = new RealtimeEvent(kind, id, at);
|
||||
foreach (var channel in clients.Values)
|
||||
{
|
||||
channel.Writer.TryWrite(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A tenant-scoped change notification: <c>Kind</c> is a component name (e.g. "workflow"),
|
||||
/// <c>Id</c> is that component's server id/path, <c>At</c> is the server-side write time.</summary>
|
||||
public record RealtimeEvent(string Kind, string Id, DateTime At);
|
||||
Loading…
Reference in a new issue