diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:58:15 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:58:15 +0800 |
| commit | 7b29dbff7e43e87741476f47df17ad1582b16546 (patch) | |
| tree | 4c9d0832b4e4edd77e59e9cb0a88abf5e729a9f9 /src/SomhairlesDream.Server/ArtifactRunCoordinator.fs | |
| parent | 47add3088d50f12c5f48591f26c53cdb59d8283b (diff) | |
| download | somhairles-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.fs | 141 |
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 |
