From 7b29dbff7e43e87741476f47df17ad1582b16546 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 21 Sep 2026 00:58:15 +0800 Subject: feat(pipeline): add runnable artifact pipeline increment [Change Nature] - This commit adds the first runnable increment of the F# artifact pipeline: CLI, server, frontend bundle, stage fixture, and tests. It is feature work, not a bug fix. [New Capability] - CLI `run` executes the three-step Blender fixture through the process bridge and writes GLBs, step logs, events, and a manifest; `verify` validates a manifest without Blender. - Server exposes run start, run-scoped SSE, artifact manifest/file routes, and serves the Fable frontend from its build output. - Frontend starts runs, follows run-scoped SSE progress, and links the artifact manifest on completion. [Implementation] - Shared layer holds domain, run state, replay, and artifact contracts; Modeling holds the pipeline, 120s bridge timeout, and result-file protocol; stage/blender/fixture.py owns geometry in Python (bpy owns geometry, F# owns orchestration). - Fable bundle is regenerated into public/ and copied by the server project; solution includes all projects with warnings-as-errors. - README documents build, Fable, server, interpreter/env, fixture limitations, and current verification status. [Impact] - Verified: 40/40 solution tests pass; Release build 0 warnings/errors; real CLI run with bpy 5.0.1 produced 3 GLBs, 3 logs, and a verify-clean manifest; isolated-port server smoke flow succeeded. - Not yet verified: browser end-to-end behavior and production fidelity. Artifacts and caches are gitignored. --- src/SomhairlesDream.Server/Program.fs | 97 +++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/SomhairlesDream.Server/Program.fs (limited to 'src/SomhairlesDream.Server/Program.fs') diff --git a/src/SomhairlesDream.Server/Program.fs b/src/SomhairlesDream.Server/Program.fs new file mode 100644 index 0000000..53cfef6 --- /dev/null +++ b/src/SomhairlesDream.Server/Program.fs @@ -0,0 +1,97 @@ +open System +open System.IO +open System.Text.Json +open System.Threading +open System.Threading.Tasks +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Hosting +open Microsoft.AspNetCore.Http +open Microsoft.Extensions.FileProviders +open Microsoft.Extensions.Hosting +open SomhairlesDream.Server +open SomhairlesDream.Shared + +let builder = WebApplication.CreateBuilder(Environment.GetCommandLineArgs() |> Array.skip 1) +let publicRoot = Path.Combine(AppContext.BaseDirectory, "public") +builder.Environment.WebRootPath <- publicRoot +builder.Environment.WebRootFileProvider <- new PhysicalFileProvider(publicRoot) + +let app = builder.Build() +let store = RunStore("heritage-001", "run-001", TimeSpan.FromMinutes 2.) +let jsonOptions = JsonSerializerOptions(JsonSerializerDefaults.Web) + +let environmentOrDefault name fallback = + match Environment.GetEnvironmentVariable(name) with + | value when not (String.IsNullOrWhiteSpace(value)) -> value + | _ -> fallback + +let artifactRoot = environmentOrDefault "SOMHAIRLES_ARTIFACT_ROOT" (Path.Combine(Environment.CurrentDirectory, ".artifacts")) +let stageRoot = environmentOrDefault "SOMHAIRLES_STAGE_ROOT" (Path.Combine(Environment.CurrentDirectory, "stage")) +let python = environmentOrDefault "SOMHAIRLES_BPYTHON" "python3" +let artifactClock () = DateTimeOffset.UtcNow +let coordinator = + ArtifactRunCoordinator( + artifactRoot, + (fun () -> SomhairlesDream.Modeling.BlenderProcessBridge(python, stageRoot, TimeSpan.FromSeconds 120.) :> SomhairlesDream.Modeling.IArtifactBridge), + artifactClock, + TimeSpan.FromMinutes 2. + ) + +let json value = Results.Json(value, jsonOptions) + +let events (context: HttpContext) : Task = + (task { + context.Response.ContentType <- "text/event-stream" + context.Response.Headers.CacheControl <- "no-cache" + + let reader = store.Subscribe() + + try + while not context.RequestAborted.IsCancellationRequested do + let! snapshot = reader.ReadAsync(context.RequestAborted).AsTask() + let payload = JsonSerializer.Serialize(snapshot, jsonOptions) + do! context.Response.WriteAsync($"data: {payload}\n\n", context.RequestAborted) + do! context.Response.Body.FlushAsync(context.RequestAborted) + with + | :? OperationCanceledException -> () + } :> Task) + +app.UseDefaultFiles() |> ignore +app.UseStaticFiles() |> ignore + +app.MapGet("/health", Func(fun () -> Results.Ok({| status = "ok" |}))) |> ignore + +ArtifactRunApi.register + app + { Coordinator = coordinator + Clock = artifactClock + JsonOptions = jsonOptions } + +let observeRuns = + Task.Run( + Func(fun () -> + task { + try + while not app.Lifetime.ApplicationStopping.IsCancellationRequested do + coordinator.ObserveAll() |> ignore + do! Task.Delay(TimeSpan.FromSeconds 1., app.Lifetime.ApplicationStopping) + with + | :? OperationCanceledException -> () + } :> Task) + ) + +app.MapGet( + "/api/state", + Func(fun () -> store.Observe(DateTimeOffset.UtcNow) |> json) +) +|> ignore + +app.MapPost( + "/api/runs/heartbeat", + Func(fun () -> store.Heartbeat(DateTimeOffset.UtcNow) |> json) +) +|> ignore + +app.MapGet("/api/events", Func(events)) |> ignore + +app.Run() -- cgit v1.2.3