summaryrefslogtreecommitdiff
path: root/src/SomhairlesDream.Modeling
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 00:58:15 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 00:58:15 +0800
commit7b29dbff7e43e87741476f47df17ad1582b16546 (patch)
tree4c9d0832b4e4edd77e59e9cb0a88abf5e729a9f9 /src/SomhairlesDream.Modeling
parent47add3088d50f12c5f48591f26c53cdb59d8283b (diff)
downloadsomhairles-dream-fsharp-7b29dbff7e43e87741476f47df17ad1582b16546.tar.gz
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.
Diffstat (limited to 'src/SomhairlesDream.Modeling')
-rw-r--r--src/SomhairlesDream.Modeling/BlenderBridge.fs204
-rw-r--r--src/SomhairlesDream.Modeling/Definitions.fs31
-rw-r--r--src/SomhairlesDream.Modeling/Pipeline.fs469
-rw-r--r--src/SomhairlesDream.Modeling/Placeholder.fs4
-rw-r--r--src/SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj15
5 files changed, 723 insertions, 0 deletions
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<JsonElement>
+
+ if root.TryGetProperty("renderPath", &property) then
+ Some(property.GetString())
+ else
+ None
+
+ let renderedAt =
+ let mutable property = Unchecked.defaultof<JsonElement>
+
+ 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)
diff --git a/src/SomhairlesDream.Modeling/Definitions.fs b/src/SomhairlesDream.Modeling/Definitions.fs
new file mode 100644
index 0000000..64e6bc6
--- /dev/null
+++ b/src/SomhairlesDream.Modeling/Definitions.fs
@@ -0,0 +1,31 @@
+namespace SomhairlesDream.Modeling
+
+open SomhairlesDream.Shared
+
+[<CLIMutable>]
+type DesignStep =
+ { StepId: string
+ Index: int
+ ObjectIds: string array
+ Changes: ArtifactChange array }
+
+module Design =
+ let steps : DesignStep array =
+ [| { StepId = "01-foundation"; Index = 0
+ ObjectIds = [| "heritage.foundation"; "heritage.deck" |]
+ Changes =
+ [| { ObjectId = "heritage.foundation"; Kind = "add"; Summary = "Add foundation slab" }
+ { ObjectId = "heritage.deck"; Kind = "add"; Summary = "Add raised deck" } |] }
+ { StepId = "02-frame"; Index = 1
+ ObjectIds =
+ [| "heritage.foundation"; "heritage.deck"; "heritage.frame.left"; "heritage.frame.right"; "heritage.spine" |]
+ Changes =
+ [| { ObjectId = "heritage.frame.left"; Kind = "add"; Summary = "Add primary left frame" }
+ { ObjectId = "heritage.frame.right"; Kind = "add"; Summary = "Add primary right frame" }
+ { ObjectId = "heritage.spine"; Kind = "add"; Summary = "Add central spine" } |] }
+ { StepId = "03-cabin"; Index = 2
+ ObjectIds =
+ [| "heritage.foundation"; "heritage.deck"; "heritage.frame.left"; "heritage.frame.right"; "heritage.spine"; "heritage.cabin"; "heritage.crossbeam" |]
+ Changes =
+ [| { ObjectId = "heritage.cabin"; Kind = "add"; Summary = "Add cabin volume" }
+ { ObjectId = "heritage.crossbeam"; Kind = "add"; Summary = "Add cabin crossbeam" } |] } |]
diff --git a/src/SomhairlesDream.Modeling/Pipeline.fs b/src/SomhairlesDream.Modeling/Pipeline.fs
new file mode 100644
index 0000000..3f1f105
--- /dev/null
+++ b/src/SomhairlesDream.Modeling/Pipeline.fs
@@ -0,0 +1,469 @@
+namespace SomhairlesDream.Modeling
+
+open System
+open System.IO
+open System.Security.Cryptography
+open System.Text
+open System.Text.Json
+open System.Text.Json.Nodes
+open SomhairlesDream.Shared
+
+[<CLIMutable>]
+type BridgeRequest =
+ { Step: DesignStep
+ GlbPath: string
+ RenderPath: string option
+ ResultPath: string
+ LogPath: string }
+
+[<CLIMutable>]
+type BridgeResult =
+ { BlenderVersion: string
+ ExportedAt: DateTimeOffset
+ ObjectIds: string array
+ VerifiedObjectIds: string array
+ GlbBytes: int64
+ RenderPath: string option
+ RenderedAt: DateTimeOffset option }
+
+type IArtifactBridge =
+ abstract Export: request: BridgeRequest * heartbeat: (unit -> unit) -> Result<BridgeResult, string>
+
+[<CLIMutable>]
+type PipelineOptions =
+ { ArtifactRoot: string
+ Ids: RunIds
+ Render: bool
+ Bridge: IArtifactBridge
+ Clock: unit -> DateTimeOffset
+ OnEvent: RunEvent -> unit }
+
+module private ArtifactFiles =
+ let runDirectory (root: string) (ids: RunIds) = Path.Combine(root, ids.ProjectId, ids.RunId)
+
+ let toSystemPath (relativePath: string) =
+ relativePath.Replace('/', Path.DirectorySeparatorChar)
+
+ let sha256 path =
+ use stream = File.OpenRead(path)
+ use sha = SHA256.Create()
+ sha.ComputeHash(stream) |> Convert.ToHexString |> fun value -> value.ToLowerInvariant()
+
+ let byteCount path = FileInfo(path).Length
+
+module private ManifestJson =
+ let private options () =
+ let value = JsonSerializerOptions()
+ value.WriteIndented <- true
+ value
+
+ let private stringArray (values: string array) =
+ let result = JsonArray()
+
+ for value in values do
+ result.Add(JsonValue.Create(value))
+
+ result :> JsonNode
+
+ let private change (value: ArtifactChange) =
+ let json = JsonObject()
+ json["objectId"] <- JsonValue.Create(value.ObjectId)
+ json["kind"] <- JsonValue.Create(value.Kind)
+ json["summary"] <- JsonValue.Create(value.Summary)
+ json :> JsonNode
+
+ let private changes (values: ArtifactChange array) =
+ let result = JsonArray()
+
+ for value in values do
+ result.Add(change value)
+
+ result :> JsonNode
+
+ let private render (value: RenderArtifact) =
+ let json = JsonObject()
+ json["artifactPath"] <- JsonValue.Create(value.ArtifactPath)
+ json["sha256"] <- JsonValue.Create(value.Sha256)
+ json["bytes"] <- JsonValue.Create(value.Bytes)
+ json["exportedAt"] <- JsonValue.Create(value.ExportedAt)
+ json :> JsonNode
+
+ let private step (value: StepArtifact) =
+ let json = JsonObject()
+ json["stepId"] <- JsonValue.Create(value.StepId)
+ json["stepIndex"] <- JsonValue.Create(value.StepIndex)
+ json["artifactPath"] <- JsonValue.Create(value.ArtifactPath)
+ json["logPath"] <- JsonValue.Create(value.LogPath)
+ json["sha256"] <- JsonValue.Create(value.Sha256)
+ json["bytes"] <- JsonValue.Create(value.Bytes)
+ json["exportedAt"] <- JsonValue.Create(value.ExportedAt)
+ json["objectIds"] <- stringArray value.ObjectIds
+ json["verifiedObjectIds"] <- stringArray value.VerifiedObjectIds
+ json["changes"] <- changes value.Changes
+
+ match value.Render with
+ | Some renderValue -> json["render"] <- render renderValue
+ | None -> ()
+
+ json :> JsonNode
+
+ let serialize (manifest: ArtifactManifest) =
+ let json = JsonObject()
+ let steps = JsonArray()
+
+ for value in manifest.Steps do
+ steps.Add(step value)
+
+ json["schemaVersion"] <- JsonValue.Create(manifest.SchemaVersion)
+ json["projectId"] <- JsonValue.Create(manifest.ProjectId)
+ json["runId"] <- JsonValue.Create(manifest.RunId)
+ json["baseId"] <- JsonValue.Create(manifest.BaseId)
+ json["targetId"] <- JsonValue.Create(manifest.TargetId)
+ json["status"] <- JsonValue.Create(manifest.Status)
+ json["startedAt"] <- JsonValue.Create(manifest.StartedAt)
+ json["completedAt"] <- JsonValue.Create(manifest.CompletedAt)
+ json["blenderVersion"] <- JsonValue.Create(manifest.BlenderVersion)
+ json["steps"] <- steps
+ json.ToJsonString(options())
+
+module Pipeline =
+ let private validateIds (ids: RunIds) =
+ [| ids.ProjectId; ids.RunId; ids.BaseId; ids.TargetId |]
+ |> Array.tryFind (Ids.validateResourceId >> Result.isError)
+ |> function
+ | Some invalid -> Error $"invalid resource id: {invalid}"
+ | None -> Ok ()
+
+ let private appendEvent (log: StreamWriter) (onEvent: RunEvent -> unit) event =
+ log.WriteLine(RunEvent.toJson event)
+ log.Flush()
+ log.BaseStream.Flush()
+ onEvent event
+
+ let private writeManifest path manifest =
+ let temporaryPath = path + ".tmp"
+ let bytes = Encoding.UTF8.GetBytes(ManifestJson.serialize manifest)
+
+ use stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough)
+ stream.Write(bytes, 0, bytes.Length)
+ stream.Flush(true)
+ File.Move(temporaryPath, path)
+
+ let private verifyBridgeResult (step: DesignStep) (request: BridgeRequest) (result: BridgeResult) =
+ if not (File.Exists(request.GlbPath)) || ArtifactFiles.byteCount request.GlbPath = 0L then
+ Error $"bridge did not produce a non-empty GLB for {step.StepId}"
+ elif result.GlbBytes <> ArtifactFiles.byteCount request.GlbPath then
+ Error $"bridge GLB byte count did not match for {step.StepId}"
+ elif result.ExportedAt.Offset <> TimeSpan.Zero then
+ Error $"bridge export timestamp was not UTC for {step.StepId}"
+ elif result.RenderedAt |> Option.exists (fun value -> value.Offset <> TimeSpan.Zero) then
+ Error $"bridge render timestamp was not UTC for {step.StepId}"
+ elif request.RenderPath.IsSome <> result.RenderPath.IsSome then
+ Error $"bridge render result did not match the request for {step.StepId}"
+ elif request.RenderPath.IsSome && request.RenderPath <> result.RenderPath then
+ Error $"bridge render path did not match the request for {step.StepId}"
+ elif result.ObjectIds <> step.ObjectIds || result.VerifiedObjectIds <> step.ObjectIds then
+ Error $"object ids did not match expected values for {step.StepId}"
+ elif result.ObjectIds |> Array.exists (Ids.validateObjectId >> Result.isError) then
+ Error $"bridge returned an invalid object id for {step.StepId}"
+ else
+ Ok ()
+
+ let private renderArtifact runDirectory (step: DesignStep) path renderedAt =
+ if not (File.Exists(path)) || ArtifactFiles.byteCount path = 0L then
+ Error $"bridge did not produce a non-empty render for {step.StepId}"
+ else
+ let relativePath = $"steps/{step.StepId}.png"
+ let destination = Path.Combine(runDirectory, ArtifactFiles.toSystemPath relativePath)
+ File.Move(path, destination)
+
+ Ok
+ { ArtifactPath = relativePath
+ Sha256 = ArtifactFiles.sha256 destination
+ Bytes = ArtifactFiles.byteCount destination
+ ExportedAt = renderedAt }
+
+ let run (options: PipelineOptions) =
+ match validateIds options.Ids with
+ | Error message -> Error message
+ | Ok () ->
+ let runDirectory = ArtifactFiles.runDirectory options.ArtifactRoot options.Ids
+
+ if Directory.Exists(runDirectory) then
+ Error "run directory already exists"
+ else
+ let stepsDirectory = Path.Combine(runDirectory, "steps")
+ let logsDirectory = Path.Combine(runDirectory, "logs")
+ let stagingDirectory = Path.Combine(runDirectory, ".staging")
+ let eventsPath = Path.Combine(runDirectory, "events.ndjson")
+ let manifestPath = Path.Combine(runDirectory, "manifest.json")
+ let startedAt = options.Clock()
+ let mutable currentStep: string option = None
+ let mutable eventLog: StreamWriter option = None
+ let mutable blenderVersion: string option = None
+
+ try
+ Directory.CreateDirectory(runDirectory) |> ignore
+ Directory.CreateDirectory(stepsDirectory) |> ignore
+ Directory.CreateDirectory(logsDirectory) |> ignore
+ Directory.CreateDirectory(stagingDirectory) |> ignore
+
+ use log = new StreamWriter(File.Open(eventsPath, FileMode.CreateNew, FileAccess.Write, FileShare.Read), UTF8Encoding(false))
+ eventLog <- Some log
+
+ let emit event = appendEvent log options.OnEvent event
+ let ids = options.Ids
+
+ emit (RunEvent.Start { Ids = ids; At = startedAt; TotalSteps = Design.steps.Length })
+
+ let artifacts =
+ [| for step in Design.steps do
+ currentStep <- Some step.StepId
+ emit (RunEvent.Heartbeat { Ids = ids; At = options.Clock(); Message = $"starting {step.StepId}" })
+
+ let glbStagingPath = Path.GetFullPath(Path.Combine(stagingDirectory, $"{step.StepId}.glb"))
+ let renderStagingPath = Path.GetFullPath(Path.Combine(stagingDirectory, $"{step.StepId}.png"))
+ let resultStagingPath = Path.GetFullPath(Path.Combine(stagingDirectory, $"{step.StepId}.result.json"))
+ let logPath = Path.GetFullPath(Path.Combine(logsDirectory, $"{step.StepId}.log"))
+ let request =
+ { Step = step
+ GlbPath = glbStagingPath
+ RenderPath = if options.Render then Some renderStagingPath else None
+ ResultPath = resultStagingPath
+ LogPath = logPath }
+
+ let bridgeResult =
+ match options.Bridge.Export(request, fun () -> emit (RunEvent.Heartbeat { Ids = ids; At = options.Clock(); Message = $"heartbeat {step.StepId}" })) with
+ | Ok value -> value
+ | Error message -> failwith message
+
+ blenderVersion <- Some bridgeResult.BlenderVersion
+
+ match verifyBridgeResult step request bridgeResult with
+ | Ok () -> ()
+ | Error message -> failwith message
+
+ let relativePath = $"steps/{step.StepId}.glb"
+ let destination = Path.Combine(runDirectory, ArtifactFiles.toSystemPath relativePath)
+ File.Move(glbStagingPath, destination)
+
+ let render =
+ if options.Render then
+ let renderedAt = bridgeResult.RenderedAt |> Option.defaultValue bridgeResult.ExportedAt
+
+ match renderArtifact runDirectory step renderStagingPath renderedAt with
+ | Ok value -> Some value
+ | Error message -> failwith message
+ else
+ None
+
+ let artifact =
+ { StepId = step.StepId
+ StepIndex = step.Index
+ ArtifactPath = relativePath
+ LogPath = $"logs/{step.StepId}.log"
+ Sha256 = ArtifactFiles.sha256 destination
+ Bytes = ArtifactFiles.byteCount destination
+ ExportedAt = bridgeResult.ExportedAt
+ ObjectIds = step.ObjectIds
+ VerifiedObjectIds = bridgeResult.VerifiedObjectIds
+ Changes = step.Changes
+ Render = render }
+
+ let checkpoint =
+ { Ids = ids
+ At = options.Clock()
+ StepId = step.StepId
+ StepIndex = step.Index
+ TotalSteps = Design.steps.Length
+ Message = "artifact verified"
+ ArtifactPath = relativePath }
+
+ emit (RunEvent.Checkpoint checkpoint)
+ yield artifact |]
+
+ let completedAt = options.Clock()
+
+ let manifest =
+ { SchemaVersion = 1
+ ProjectId = ids.ProjectId
+ RunId = ids.RunId
+ BaseId = ids.BaseId
+ TargetId = ids.TargetId
+ Status = "complete"
+ StartedAt = startedAt
+ CompletedAt = completedAt
+ BlenderVersion = blenderVersion |> Option.defaultValue "unknown"
+ Steps = artifacts }
+
+ writeManifest manifestPath manifest
+ emit (RunEvent.Complete { Ids = ids; At = completedAt; ManifestPath = "manifest.json" })
+ currentStep <- None
+ if Directory.Exists(stagingDirectory) then
+ Directory.Delete(stagingDirectory, true)
+ Ok manifest
+ with ex ->
+ match eventLog with
+ | Some log ->
+ try
+ appendEvent log options.OnEvent (RunEvent.Fail { Ids = options.Ids; At = options.Clock(); StepId = currentStep; Message = ex.Message })
+ with _ -> ()
+ | None -> ()
+
+ if Directory.Exists(stagingDirectory) then
+ Directory.Delete(stagingDirectory, true)
+
+ Error ex.Message
+
+module ArtifactVerifier =
+ let private stringProperty (value: JsonElement) (name: string) =
+ let result = value.GetProperty(name).GetString()
+
+ if isNull result then
+ failwith $"manifest property '{name}' is null"
+ else
+ result
+
+ let private parseRender (value: JsonElement) =
+ let mutable renderValue = Unchecked.defaultof<JsonElement>
+
+ if value.TryGetProperty("render", &renderValue) then
+ Some
+ { ArtifactPath = stringProperty renderValue "artifactPath"
+ Sha256 = stringProperty renderValue "sha256"
+ Bytes = renderValue.GetProperty("bytes").GetInt64()
+ ExportedAt = renderValue.GetProperty("exportedAt").GetDateTimeOffset() }
+ else
+ None
+
+ let private parseStep (value: JsonElement) : StepArtifact =
+ { StepId = stringProperty value "stepId"
+ StepIndex = value.GetProperty("stepIndex").GetInt32()
+ ArtifactPath = stringProperty value "artifactPath"
+ LogPath = stringProperty value "logPath"
+ Sha256 = stringProperty value "sha256"
+ Bytes = value.GetProperty("bytes").GetInt64()
+ ExportedAt = value.GetProperty("exportedAt").GetDateTimeOffset()
+ ObjectIds = value.GetProperty("objectIds").EnumerateArray() |> Seq.map (fun item -> item.GetString()) |> Seq.toArray
+ VerifiedObjectIds = value.GetProperty("verifiedObjectIds").EnumerateArray() |> Seq.map (fun item -> item.GetString()) |> Seq.toArray
+ Changes =
+ value.GetProperty("changes").EnumerateArray()
+ |> Seq.map (fun item ->
+ { ObjectId = stringProperty item "objectId"
+ Kind = stringProperty item "kind"
+ Summary = stringProperty item "summary" })
+ |> Seq.toArray
+ Render = parseRender value }
+
+ let private parseManifest (value: JsonElement) : ArtifactManifest =
+ { SchemaVersion = value.GetProperty("schemaVersion").GetInt32()
+ ProjectId = stringProperty value "projectId"
+ RunId = stringProperty value "runId"
+ BaseId = stringProperty value "baseId"
+ TargetId = stringProperty value "targetId"
+ Status = stringProperty value "status"
+ StartedAt = value.GetProperty("startedAt").GetDateTimeOffset()
+ CompletedAt = value.GetProperty("completedAt").GetDateTimeOffset()
+ BlenderVersion = stringProperty value "blenderVersion"
+ Steps = value.GetProperty("steps").EnumerateArray() |> Seq.map parseStep |> Seq.toArray }
+
+ let private safePath runDirectory relativePath =
+ if String.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath) then
+ Error "artifact path must be relative"
+ else
+ let normalized = relativePath.Replace('\\', '/')
+ let segments = normalized.Split('/', StringSplitOptions.RemoveEmptyEntries)
+
+ if segments |> Array.exists (fun segment -> segment = ".." || segment = ".") then
+ Error "artifact path contains traversal"
+ else
+ let fullPath = Path.GetFullPath(Path.Combine(runDirectory, ArtifactFiles.toSystemPath normalized))
+ let prefix = Path.GetFullPath(runDirectory).TrimEnd(Path.DirectorySeparatorChar) + string Path.DirectorySeparatorChar
+
+ if fullPath.StartsWith(prefix, StringComparison.Ordinal) then
+ Ok fullPath
+ else
+ Error "artifact path escapes run directory"
+
+ let private verifyFile runDirectory expectedPath expectedHash expectedBytes =
+ match safePath runDirectory expectedPath with
+ | Error message -> Error message
+ | Ok path when not (File.Exists(path)) -> Error $"artifact is missing: {expectedPath}"
+ | Ok path when FileInfo(path).Length <> expectedBytes -> Error $"artifact byte count mismatch: {expectedPath}"
+ | Ok path when ArtifactFiles.sha256 path <> expectedHash -> Error $"artifact hash mismatch: {expectedPath}"
+ | Ok _ -> Ok ()
+
+ let private verifyLogFile runDirectory expectedPath =
+ match safePath runDirectory expectedPath with
+ | Error message -> Error message
+ | Ok path when not (File.Exists(path)) -> Error $"log is missing: {expectedPath}"
+ | Ok path when FileInfo(path).Length = 0L -> Error $"log is empty: {expectedPath}"
+ | Ok _ -> Ok ()
+
+ let verify manifestPath =
+ try
+ if not (File.Exists(manifestPath)) then
+ Error "manifest is missing"
+ elif Path.GetFileName(manifestPath) <> "manifest.json" then
+ Error "manifest path must be manifest.json"
+ else
+ use document = JsonDocument.Parse(File.ReadAllText(manifestPath))
+ let manifest = parseManifest document.RootElement
+ let runDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath))
+ let projectDirectory = Directory.GetParent(runDirectory).Name
+ let runDirectoryName = DirectoryInfo(runDirectory).Name
+
+ if manifest.SchemaVersion <> 1 then
+ Error "unsupported manifest schema"
+ elif manifest.Status <> "complete" then
+ Error "manifest is not complete"
+ elif manifest.ProjectId <> projectDirectory || manifest.RunId <> runDirectoryName then
+ Error "manifest identity does not match its directory"
+ elif Ids.validateResourceId manifest.ProjectId |> Result.isError
+ || Ids.validateResourceId manifest.RunId |> Result.isError
+ || Ids.validateResourceId manifest.BaseId |> Result.isError
+ || Ids.validateResourceId manifest.TargetId |> Result.isError then
+ Error "manifest contains an invalid resource id"
+ elif manifest.Steps.Length <> Design.steps.Length then
+ Error "manifest step count does not match design"
+ else
+ let mutable failure: string option = None
+
+ for expected, actual in Array.zip Design.steps manifest.Steps do
+ if failure.IsNone && actual.StepId <> expected.StepId then
+ failure <- Some $"manifest step id mismatch: {expected.StepId}"
+ elif failure.IsNone && actual.StepIndex <> expected.Index then
+ failure <- Some $"manifest step index mismatch: {expected.StepId}"
+ elif failure.IsNone && actual.ObjectIds <> expected.ObjectIds then
+ failure <- Some $"manifest source object ids mismatch: {expected.StepId}"
+ elif failure.IsNone && actual.VerifiedObjectIds <> expected.ObjectIds then
+ failure <- Some $"manifest verified object ids mismatch: {expected.StepId}"
+ elif failure.IsNone && actual.Changes <> expected.Changes then
+ failure <- Some $"manifest changes mismatch: {expected.StepId}"
+ elif failure.IsNone then
+ let expectedPath = $"steps/{expected.StepId}.glb"
+ let expectedLogPath = $"logs/{expected.StepId}.log"
+
+ if actual.ArtifactPath <> expectedPath then
+ failure <- Some $"manifest artifact path mismatch: {expected.StepId}"
+ elif actual.LogPath <> expectedLogPath then
+ failure <- Some $"manifest log path mismatch: {expected.StepId}"
+ else
+ match verifyLogFile runDirectory actual.LogPath with
+ | Error message -> failure <- Some message
+ | Ok () ->
+ match verifyFile runDirectory actual.ArtifactPath actual.Sha256 actual.Bytes with
+ | Error message -> failure <- Some message
+ | Ok () ->
+ match actual.Render with
+ | Some render ->
+ match verifyFile runDirectory render.ArtifactPath render.Sha256 render.Bytes with
+ | Error message -> failure <- Some message
+ | Ok () -> ()
+ | None -> ()
+
+ match failure with
+ | Some message -> Error message
+ | None -> Ok manifest
+ with ex ->
+ Error $"invalid manifest: {ex.Message}"
diff --git a/src/SomhairlesDream.Modeling/Placeholder.fs b/src/SomhairlesDream.Modeling/Placeholder.fs
new file mode 100644
index 0000000..b5e4368
--- /dev/null
+++ b/src/SomhairlesDream.Modeling/Placeholder.fs
@@ -0,0 +1,4 @@
+namespace SomhairlesDream.Modeling
+
+module Placeholder =
+ let ready = false
diff --git a/src/SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj b/src/SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj
new file mode 100644
index 0000000..cf9312e
--- /dev/null
+++ b/src/SomhairlesDream.Modeling/SomhairlesDream.Modeling.fsproj
@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net8.0</TargetFramework>
+ <RootNamespace>SomhairlesDream.Modeling</RootNamespace>
+ <AssemblyName>SomhairlesDream.Modeling</AssemblyName>
+ </PropertyGroup>
+ <ItemGroup>
+ <ProjectReference Include="..\SomhairlesDream.Shared\SomhairlesDream.Shared.fsproj" />
+ </ItemGroup>
+ <ItemGroup>
+ <Compile Include="Definitions.fs" />
+ <Compile Include="Pipeline.fs" />
+ <Compile Include="BlenderBridge.fs" />
+ </ItemGroup>
+</Project>