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.Modeling/BlenderBridge.fs | 204 ++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 src/SomhairlesDream.Modeling/BlenderBridge.fs (limited to 'src/SomhairlesDream.Modeling/BlenderBridge.fs') diff --git a/src/SomhairlesDream.Modeling/BlenderBridge.fs b/src/SomhairlesDream.Modeling/BlenderBridge.fs new file mode 100644 index 0000000..c0795e9 --- /dev/null +++ b/src/SomhairlesDream.Modeling/BlenderBridge.fs @@ -0,0 +1,204 @@ +namespace SomhairlesDream.Modeling + +open System +open System.Diagnostics +open System.IO +open System.Text.Json + +type BlenderProcessBridge(python: string, stageRoot: string, ?timeout: TimeSpan) = + let timeout = defaultArg timeout (TimeSpan.FromSeconds 120.) + + let bridgeError message = + if String.IsNullOrWhiteSpace(message) then + "blender bridge: process failed without diagnostics" + else + $"blender bridge: {message.Trim()}" + + let diagnostics (stderr: string) (stdout: string) = + let value = + if String.IsNullOrWhiteSpace(stderr) then stdout else stderr + + if String.IsNullOrWhiteSpace(value) then + "no process diagnostics" + elif value.Length > 2000 then + value.Substring(0, 2000) + else + value + + let strings (root: JsonElement) (name: string) = + let property = root.GetProperty(name) + + if property.ValueKind <> JsonValueKind.Array then + failwith $"'{name}' must be an array" + + let values = property.EnumerateArray() |> Seq.map (fun item -> item.GetString()) |> Seq.toArray + + if values |> Array.exists isNull then + failwith $"'{name}' contains a null value" + + values + + let utcTimestamp (root: JsonElement) (name: string) = + let value = root.GetProperty(name).GetDateTimeOffset() + + if value.Offset <> TimeSpan.Zero then + failwith $"'{name}' must be a UTC timestamp" + + value + + let parseResult (request: BridgeRequest) = + if not (File.Exists(request.ResultPath)) then + failwith $"fixture result file is missing: {request.ResultPath}" + + use document = JsonDocument.Parse(File.ReadAllText(request.ResultPath)) + let root = document.RootElement + + if not (root.GetProperty("ok").GetBoolean()) then + failwith "fixture returned ok=false" + + let renderPath = + let mutable property = Unchecked.defaultof + + if root.TryGetProperty("renderPath", &property) then + Some(property.GetString()) + else + None + + let renderedAt = + let mutable property = Unchecked.defaultof + + if root.TryGetProperty("renderedAt", &property) then + Some(utcTimestamp root "renderedAt") + else + None + + if request.RenderPath.IsSome <> renderPath.IsSome then + failwith "fixture render result did not match the request" + + if request.RenderPath.IsSome && request.RenderPath <> renderPath then + failwith "fixture returned an unexpected render path" + + { BlenderVersion = root.GetProperty("blenderVersion").GetString() + ExportedAt = utcTimestamp root "exportedAt" + ObjectIds = strings root "objectIds" + VerifiedObjectIds = strings root "verifiedObjectIds" + GlbBytes = root.GetProperty("glbBytes").GetInt64() + RenderPath = renderPath + RenderedAt = renderedAt } + + let writeProcessLog (path: string) (stdout: string) (stderr: string) = + match Path.GetDirectoryName(path) with + | null -> () + | directory -> Directory.CreateDirectory(directory) |> ignore + + let content = + String.concat + Environment.NewLine + [| "[stdout]" + stdout + "[stderr]" + stderr |] + + File.WriteAllText(path, content) + + member private _.StartInfo(request: BridgeRequest) = + let scriptPath = Path.GetFullPath(Path.Combine(stageRoot, "blender", "fixture.py")) + let info = ProcessStartInfo() + info.FileName <- python + info.UseShellExecute <- false + info.CreateNoWindow <- true + info.RedirectStandardOutput <- true + info.RedirectStandardError <- true + info.ArgumentList.Add(scriptPath) + info.ArgumentList.Add("--step-id") + info.ArgumentList.Add(request.Step.StepId) + info.ArgumentList.Add("--output") + info.ArgumentList.Add(Path.GetFullPath(request.GlbPath)) + info.ArgumentList.Add("--result-output") + info.ArgumentList.Add(Path.GetFullPath(request.ResultPath)) + + match request.RenderPath with + | Some path -> + info.ArgumentList.Add("--render-output") + info.ArgumentList.Add(Path.GetFullPath(path)) + | None -> () + + info + + interface IArtifactBridge with + member this.Export(request, heartbeat) = + try + let scriptPath = Path.GetFullPath(Path.Combine(stageRoot, "blender", "fixture.py")) + + if not (File.Exists(scriptPath)) then + Error(bridgeError $"fixture is missing: {scriptPath}") + else + match Path.GetDirectoryName(request.GlbPath) with + | null -> () + | directory -> Directory.CreateDirectory(directory) |> ignore + + match request.RenderPath with + | Some path -> + match Path.GetDirectoryName(path) with + | null -> () + | directory -> Directory.CreateDirectory(directory) |> ignore + | None -> () + + match Path.GetDirectoryName(request.ResultPath) with + | null -> () + | directory -> Directory.CreateDirectory(directory) |> ignore + + match Path.GetDirectoryName(request.LogPath) with + | null -> () + | directory -> Directory.CreateDirectory(directory) |> ignore + + if File.Exists(request.ResultPath) then + File.Delete(request.ResultPath) + + if File.Exists(request.LogPath) then + File.Delete(request.LogPath) + + use childProcess = new Process() + childProcess.StartInfo <- this.StartInfo(request) + + if not (childProcess.Start()) then + Error(bridgeError "process could not be started") + else + let stdoutTask = childProcess.StandardOutput.ReadToEndAsync() + let stderrTask = childProcess.StandardError.ReadToEndAsync() + let started = Stopwatch.StartNew() + let mutable timedOut = false + + while not childProcess.HasExited && not timedOut do + let remaining = timeout - started.Elapsed + + if remaining <= TimeSpan.Zero then + timedOut <- true + else + let waitMilliseconds = int (min 5000.0 remaining.TotalMilliseconds) + + if not (childProcess.WaitForExit(waitMilliseconds)) then + if childProcess.HasExited then + () + else + heartbeat () + + if timedOut && not childProcess.HasExited then + childProcess.Kill(true) + + childProcess.WaitForExit() + let stdout = stdoutTask.GetAwaiter().GetResult() + let stderr = stderrTask.GetAwaiter().GetResult() + writeProcessLog request.LogPath stdout stderr + + if timedOut then + Error(bridgeError $"process timed out after {timeout.TotalSeconds} seconds") + elif childProcess.ExitCode <> 0 then + Error(bridgeError $"process exited with code {childProcess.ExitCode}: {diagnostics stderr stdout}") + else + try + Ok(parseResult request) + with ex -> + Error(bridgeError $"invalid fixture result: {ex.Message}") + with ex -> + Error(bridgeError ex.Message) -- cgit v1.2.3