using Microsoft.EntityFrameworkCore; using Npgsql; using Serilog; using Serilog.Events; using Serilog.Sinks.OpenSearch; using StackExchange.Redis; using Scalar.AspNetCore; using w4c_workflows.Data; using w4c_workflows.Middleware; using w4c_workflows.Services; using w4c_workflows.Services.Execution; using w4c_workflows.Services.Messaging; using w4c_workflows.Services.Runs; using w4c_workflows.Services.Triggers; // --------------------------------------------------------------------------- // w4c-workflows-api — control plane for the custom workflows engine. // // Two entrypoints share this image: // - default : HTTP control plane (API, trigger scheduler, Mermaid, keys) // - --worker : per-tenant execution worker (pull/exec/result over Redis) // --------------------------------------------------------------------------- var runWorker = args.Any(a => a == "--worker"); var builder = WebApplication.CreateBuilder(args); var openSearchUrl = builder.Configuration["OpenSearch:Url"] ?? "http://localhost:9200"; var appName = builder.Configuration["APP_NAME"] ?? (runWorker ? "workflows-worker" : "workflows-api"); Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) // EF Core logs the raw SQL ("Executed DbCommand ...") at Information for // EVERY query. The background pollers (run lifecycle, trigger scheduler, // handler consumer) fire those several times a second and flood the console // / OpenSearch. Keep our own Information logs, but drop the EF command log. .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", LogEventLevel.Warning) .Enrich.FromLogContext() .Enrich.WithProperty("App", appName) .WriteTo.Console() .WriteTo.OpenSearch(new OpenSearchSinkOptions(new Uri(openSearchUrl)) { IndexFormat = "w4c-workflows-{0:yyyy.MM.dd}", AutoRegisterTemplate = false, BatchAction = OpenOpType.Create, EmitEventFailure = EmitEventFailureHandling.WriteToSelfLog, BufferBaseFilename = Path.Combine(Path.GetTempPath(), "serilog-opensearch-buffer"), }) .CreateLogger(); builder.Host.UseSerilog(); builder.Services.AddControllers(); builder.Services.AddOpenApi(); builder.Services.AddHttpClient(); builder.Services.AddHttpContextAccessor(); // Lite mode flag: when true, the engine uses SQLite + in-process channels // instead of PostgreSQL + Redis. Intended for self-hosted single-container // deployments where external infrastructure is not available. var liteMode = builder.Configuration.GetValue("UseLiteMode"); // A transient failure in ONE background service (worker loop, trigger scheduler, // run lifecycle, handler consumer) must never take the whole control plane down. // Without this, HostOptions.BackgroundServiceExceptionBehavior defaults to // StopHost and a single early exception prevents the API from starting. builder.Services.Configure(options => options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore); if (liteMode) { // Lite mode: SQLite. The connection string should be something like // "Data Source=/app/data/workflows.db". var sqliteCs = builder.Configuration.GetConnectionString("DefaultConnection") ?? "Data Source=workflows.db"; builder.Services.AddDbContext(options => options.UseSqlite(sqliteCs)); Log.Logger.Information("Lite mode: using SQLite ({ConnectionString})", sqliteCs); } else { builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); // NpgsqlDataSource for lightweight queries outside EF (e.g. login resolution). builder.Services.AddSingleton(_ => NpgsqlDataSource.Create(builder.Configuration.GetConnectionString("DefaultConnection")!)); builder.Services.AddSingleton(_ => ConnectionMultiplexer.Connect( builder.Configuration["Redis:ConnectionString"] ?? "localhost:6379,abortConnect=false")); } builder.Services.AddScoped(); // Transport + lease. if (liteMode) { // Lite mode: in-process channels replace Redis Streams. builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); Log.Logger.Information("Lite mode: using in-memory transport + trigger state (no Redis)"); } else { // Full mode: Redis Streams. builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); } // YAML compile pipeline (step 4). builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // 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. // When Forgejo:AdminToken is configured, the factory creates Forgejo-backed // sources with per-tenant repos; otherwise it falls back to filesystem-only. builder.Services.AddSingleton(); builder.Services.AddScoped(sp => { var http = sp.GetRequiredService(); var factory = sp.GetRequiredService(); // Check if a Forgejo-resolved source was already prepared by the middleware. if (http.HttpContext?.Items["WorkflowSource"] is IWorkflowSource forgejoSrc) return forgejoSrc; var tenantId = http.HttpContext?.Items["TenantId"] as string ?? throw new InvalidOperationException("TenantId not resolved by auth middleware"); return factory.Create(tenantId); }); builder.Services.AddScoped(); // Mermaid diagrams (step 6). builder.Services.AddSingleton(); // Rich HTML workflow preview (backend-rendered, embeddable in an iframe). builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Runtime registry + worker executors (step 7). // The executors map each language to its execution strategy; the registry // (which also backs GET /api/languages) reports per-runtime availability. builder.Services.AddSingleton(new SubprocessScriptExecutor("shell", "sh", builder.Configuration)); builder.Services.AddSingleton(new SubprocessScriptExecutor("python", "python3", builder.Configuration)); builder.Services.AddSingleton(new SubprocessScriptExecutor("javascript", "node", builder.Configuration)); builder.Services.AddSingleton(new TypeScriptExecutor(builder.Configuration)); builder.Services.AddSingleton(new CSharpScriptExecutor()); // W9: `agent` step type — invokes an LLM agent via chatapi over HTTP (D3), keeping // workflows-api decoupled from BotSharp. builder.Services.AddSingleton(sp => new AgentScriptExecutor( sp.GetRequiredService(), builder.Configuration, sp.GetRequiredService>())); builder.Services.AddSingleton(); // S8: remote (SSH) task execution via the w4c-webapi server-console exec endpoint. // Used only when a task carries a `server` reference; local subprocess stays the default. builder.Services.AddSingleton(); // Trigger engine (step 8): the scheduler + handler consumer run only in the // control-plane entrypoint, never in the worker. They share the run launcher — // the seam that creates pending runs for the run lifecycle engine (step 9). builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); // Run lifecycle engine (step 9): dispatch pending runs + consume task.results + // advance the nextId chain + durable checkpoint/resume. builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); if (runWorker) { // Dedicated worker process (scaled/separate deployment). builder.Services.AddHostedService(); } else { // Control-plane entrypoint. In single-node / self-hosted deployments one // process does both: it accounts, dispatches, AND runs the worker loop so // dispatched jobs actually execute. This closes the gap where the API // created run + task.run jobs but nothing consumed them, leaving every run // 'running' forever. A dedicated --worker can still be used for scale-out. builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); } builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => { var origins = builder.Configuration.GetSection("Cors:Origins").Get(); if (origins is { Length: > 0 }) { // Production: restrict to explicitly configured origins. policy.WithOrigins(origins) .AllowAnyMethod() .AllowAnyHeader() .AllowCredentials(); } else { // Dev fallback: no origins configured → allow all. policy.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader(); } }); }); var app = builder.Build(); app.UseCors(); app.UseMiddleware(); // Resolve the Forgejo-backed workflow source asynchronously after auth. // The auth middleware sets TenantId; this middleware resolves the user's Forgejo // login, clones/pulls the repo, and stores the IWorkflowSource in HttpContext.Items // so the DI registration can pick it up synchronously. app.Use(async (context, next) => { var tenantId = context.Items["TenantId"] as string; if (!string.IsNullOrEmpty(tenantId)) { var factory = context.RequestServices.GetRequiredService(); if (factory.IsForgejoBacked) { try { var source = await factory.CreateAsync(tenantId, context.RequestAborted); context.Items["WorkflowSource"] = source; // Store the resolved Forgejo login for controllers that need // to resolve repo paths (WorkflowFilesController, etc.). var login = await factory.ResolveForgejoLoginAsync(tenantId, context.RequestAborted); if (!string.IsNullOrEmpty(login)) context.Items["ForgejoLogin"] = login; } catch (Exception ex) { var logger = context.RequestServices.GetRequiredService() .CreateLogger("WorkflowSourceMiddleware"); logger.LogWarning(ex, "Forgejo-backed source failed for tenant {TenantId}, falling back to filesystem", tenantId); } } } await next(); }); // Ensure the database schema exists before serving traffic. try { using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); // Additive schema, applied idempotently (raw SQL, no new EF migration) so it is // safe on every startup against both freshly-created and long-lived databases. // Only applies to PostgreSQL (SQLite handles columns automatically via EF). if (!liteMode) { await db.Database.ExecuteSqlRawAsync( "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;"); } } catch (Exception ex) { Log.Logger.Error(ex, "Failed to apply EF Core migrations on startup"); } // Scalar API reference + OpenAPI document are exposed under a dedicated // per-service prefix (/api/scalar/workflows/...) so Caddy can route them on the // public origin without colliding with the webapi Scalar reference. The OpenAPI // document is served at the SAME prefix (not the default /openapi/v1.json) so // Scalar's relative spec + asset resolution stays consistent behind the proxy. app.MapOpenApi("/api/scalar/workflows/openapi/{documentName}.json"); app.MapScalarApiReference("/api/scalar/workflows", options => { options.OpenApiRoutePattern = "/api/scalar/workflows/openapi/{documentName}.json"; }); app.MapControllers(); if (runWorker) { Log.Logger.Information("w4c-workflows-worker entrypoint selected; worker loop running for tenant {TenantId}", builder.Configuration["Workflows:TenantId"] ?? Environment.GetEnvironmentVariable("WF_TENANT_ID") ?? "default"); } app.Run(); Log.CloseAndFlush();