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/ArtifactRunApi.fs | 161 +++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 src/SomhairlesDream.Server/ArtifactRunApi.fs (limited to 'src/SomhairlesDream.Server/ArtifactRunApi.fs') diff --git a/src/SomhairlesDream.Server/ArtifactRunApi.fs b/src/SomhairlesDream.Server/ArtifactRunApi.fs new file mode 100644 index 0000000..8d76ffc --- /dev/null +++ b/src/SomhairlesDream.Server/ArtifactRunApi.fs @@ -0,0 +1,161 @@ +namespace SomhairlesDream.Server + +open System +open System.IO +open System.Text.Json +open System.Threading.Tasks +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Http +open SomhairlesDream.Shared + +[] +type RunStartRequest = + { ProjectId: string + RunId: string + BaseId: string + TargetId: string + Render: bool } + +type ArtifactRunApiOptions = + { Coordinator: ArtifactRunCoordinator + Clock: unit -> DateTimeOffset + JsonOptions: JsonSerializerOptions } + +module ArtifactRunApi = + let private json options value = Results.Json(value, options.JsonOptions) + + let private jsonWithStatus options statusCode value = + Results.Json(value, options.JsonOptions, statusCode = Nullable(statusCode)) + + let private error options statusCode message = + jsonWithStatus options statusCode {| error = message |} + + let private readStartRequest options (request: HttpRequest) = + task { + try + let! value = request.ReadFromJsonAsync(options.JsonOptions) + + if isNull (box value) then + return Error "request body must be a JSON object" + else + return Ok value + with + | :? JsonException as ex -> return Error $"invalid JSON: {ex.Message}" + } + + let private start options (context: HttpContext) : Task = + task { + let! request = readStartRequest options context.Request + + match request with + | Error message -> return error options 400 message + | Ok request -> + let ids : RunIds = + { ProjectId = request.ProjectId + RunId = request.RunId + BaseId = request.BaseId + TargetId = request.TargetId } + + match options.Coordinator.Start(ids, request.Render) with + | Ok(Accepted snapshot) -> return jsonWithStatus options 202 snapshot + | Ok(Conflict snapshot) -> return jsonWithStatus options 409 snapshot + | Error message when message = "run registry entry disappeared" -> + return error options 500 message + | Error message -> return error options 400 message + } + + let private queryValue (context: HttpContext) name = + let value = context.Request.Query[name].ToString() + + if String.IsNullOrWhiteSpace(value) then + None + else + Some value + + let private selector context = + match queryValue context "projectId", queryValue context "runId" with + | Some projectId, Some runId -> + match Ids.validateResourceId projectId, Ids.validateResourceId runId with + | Error message, _ -> Error $"invalid projectId: {message}" + | _, Error message -> Error $"invalid runId: {message}" + | Ok (), Ok () -> Ok(projectId, runId) + | None, _ -> Error "missing query parameter 'projectId'" + | _, None -> Error "missing query parameter 'runId'" + + let private manifest options (context: HttpContext) = + match selector context with + | Error message -> error options 400 message + | Ok(projectId, runId) -> + match options.Coordinator.Manifest(projectId, runId) with + | Ok value -> json options value + | Error RunNotFound -> error options 404 "run not found" + | Error(RunNotComplete snapshot) -> jsonWithStatus options 409 snapshot + | Error(InvalidManifest message) -> error options 500 message + | Error(InvalidArtifactPath message) -> error options 500 message + | Error ArtifactNotFound -> error options 500 "manifest not found" + + let private artifactContentType (path: string) = + match Path.GetExtension(path).ToLowerInvariant() with + | ".glb" -> "model/gltf-binary" + | ".json" -> "application/json" + | ".png" -> "image/png" + | ".log" -> "text/plain" + | _ -> "application/octet-stream" + + let private artifact options (context: HttpContext) = + match selector context, queryValue context "path" with + | Error message, _ -> error options 400 message + | _, None -> error options 400 "missing query parameter 'path'" + | Ok(projectId, runId), Some relativePath -> + match options.Coordinator.Artifact(projectId, runId, relativePath) with + | Ok path -> Results.File(path, artifactContentType path) + | Error RunNotFound -> error options 404 "run not found" + | Error(RunNotComplete snapshot) -> jsonWithStatus options 409 snapshot + | Error(InvalidArtifactPath message) -> error options 400 message + | Error ArtifactNotFound -> error options 404 "artifact not found" + | Error(InvalidManifest message) -> error options 500 message + + let private writeError options (context: HttpContext) statusCode message = + task { + context.Response.StatusCode <- statusCode + context.Response.ContentType <- "application/json" + let payload = JsonSerializer.Serialize({| error = message |}, options.JsonOptions) + do! context.Response.WriteAsync(payload) + } + + let private events options (context: HttpContext) : Task = + task { + match selector context with + | Error message -> do! writeError options context 400 message + | Ok(projectId, runId) -> + match options.Coordinator.Subscribe(projectId, runId) with + | None -> do! writeError options context 404 "run not found" + | Some subscription -> + use _lifetime = subscription :> IDisposable + context.Response.StatusCode <- 200 + context.Response.ContentType <- "text/event-stream" + context.Response.Headers.CacheControl <- "no-cache" + context.Response.Headers["X-Accel-Buffering"] <- "no" + + try + while not context.RequestAborted.IsCancellationRequested do + let! snapshot = subscription.Reader.ReadAsync(context.RequestAborted).AsTask() + let payload = JsonSerializer.Serialize(snapshot, options.JsonOptions) + do! context.Response.WriteAsync($"data: {payload}\n\n", context.RequestAborted) + do! context.Response.Body.FlushAsync(context.RequestAborted) + with + | :? OperationCanceledException -> () + } + + let register (app: WebApplication) options = + app.MapPost("/api/runs/start", Func>(fun context -> start options context)) + |> ignore + + app.MapGet("/api/runs/events", Func(fun context -> events options context)) + |> ignore + + app.MapGet("/api/artifacts/manifest", Func(fun context -> manifest options context)) + |> ignore + + app.MapGet("/api/artifacts/file", Func(fun context -> artifact options context)) + |> ignore -- cgit v1.2.3