summaryrefslogtreecommitdiff
path: root/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs
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.Server/ArtifactRunCoordinator.fs
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.Server/ArtifactRunCoordinator.fs')
-rw-r--r--src/SomhairlesDream.Server/ArtifactRunCoordinator.fs141
1 files changed, 141 insertions, 0 deletions
diff --git a/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs b/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs
new file mode 100644
index 0000000..78d5dee
--- /dev/null
+++ b/src/SomhairlesDream.Server/ArtifactRunCoordinator.fs
@@ -0,0 +1,141 @@
+namespace SomhairlesDream.Server
+
+open System
+open System.IO
+open System.Threading.Tasks
+open SomhairlesDream.Modeling
+open SomhairlesDream.Shared
+
+type RunStartOutcome =
+ | Accepted of RunSnapshot
+ | Conflict of RunSnapshot
+
+type ArtifactLookupError =
+ | RunNotFound
+ | RunNotComplete of RunSnapshot
+ | InvalidArtifactPath of string
+ | ArtifactNotFound
+ | InvalidManifest of string
+
+type ArtifactRunCoordinator(
+ artifactRoot: string,
+ bridgeFactory: unit -> IArtifactBridge,
+ clock: unit -> DateTimeOffset,
+ staleAfter: TimeSpan
+) =
+ let root = Path.GetFullPath(artifactRoot)
+ let registry = ArtifactRunRegistry(staleAfter, clock)
+
+ let 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 runDirectory (projectId: string) (runId: string) =
+ Path.Combine(root, projectId, runId)
+
+ let failIfNeeded (store: ArtifactRunStore) (ids: RunIds) message =
+ let snapshot = store.Snapshot()
+
+ if snapshot.Status = "idle" || snapshot.Status = "running" then
+ store.Apply(
+ RunEvent.Fail
+ { Ids = ids
+ At = clock ()
+ StepId = snapshot.CurrentStepId
+ Message = message }
+ )
+ |> ignore
+
+ let runPipeline (store: ArtifactRunStore) (ids: RunIds) render =
+ try
+ let options : PipelineOptions =
+ { ArtifactRoot = root
+ Ids = ids
+ Render = render
+ Bridge = bridgeFactory ()
+ Clock = clock
+ OnEvent =
+ fun event ->
+ match store.Apply event with
+ | Ok _ -> ()
+ | Error message -> invalidOp message }
+
+ match Pipeline.run options with
+ | Ok _ -> ()
+ | Error message -> failIfNeeded store ids message
+ with ex ->
+ failIfNeeded store ids ex.Message
+
+ let safeArtifactPath (runDirectory: string) (relativePath: string) =
+ 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, normalized.Replace('/', Path.DirectorySeparatorChar)))
+ 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"
+
+ member _.ArtifactRoot = root
+
+ member _.Start(ids: RunIds, render: bool) : Result<RunStartOutcome, string> =
+ match validateIds ids with
+ | Error message -> Error message
+ | Ok () ->
+ match registry.TryCreate(ids) with
+ | Some store ->
+ Task.Run(fun () -> runPipeline store ids render) |> ignore
+ Ok(Accepted(store.Snapshot()))
+ | None ->
+ match registry.TryFind(ids.ProjectId, ids.RunId) with
+ | Some store -> Ok(Conflict(store.Observe(clock ())))
+ | None -> Error "run registry entry disappeared"
+
+ member _.TryFind(projectId: string, runId: string) = registry.TryFind(projectId, runId)
+
+ member _.Subscribe(projectId: string, runId: string) =
+ registry.TryFind(projectId, runId) |> Option.map (fun store -> store.Subscribe())
+
+ member _.ObserveAll() = registry.ObserveAll(clock ())
+
+ member _.Manifest(projectId: string, runId: string) : Result<ArtifactManifest, ArtifactLookupError> =
+ match registry.TryFind(projectId, runId) with
+ | None -> Error RunNotFound
+ | Some store ->
+ let snapshot = store.Observe(clock ())
+
+ if snapshot.Status <> "complete" then
+ Error(RunNotComplete snapshot)
+ else
+ let path = Path.Combine(runDirectory projectId runId, "manifest.json")
+
+ match ArtifactVerifier.verify path with
+ | Ok manifest -> Ok manifest
+ | Error message -> Error(InvalidManifest message)
+
+ member _.Artifact(projectId: string, runId: string, relativePath: string) : Result<string, ArtifactLookupError> =
+ match registry.TryFind(projectId, runId) with
+ | None -> Error RunNotFound
+ | Some store ->
+ let snapshot = store.Observe(clock ())
+
+ if snapshot.Status <> "complete" then
+ Error(RunNotComplete snapshot)
+ else
+ let directory = runDirectory projectId runId
+
+ match safeArtifactPath directory relativePath with
+ | Error message -> Error(InvalidArtifactPath message)
+ | Ok path when not (File.Exists(path)) -> Error ArtifactNotFound
+ | Ok path -> Ok path