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.Shared/Artifact.fs | 218 +++++++++++++++++++++ src/SomhairlesDream.Shared/Domain.fs | 102 ++++++++++ src/SomhairlesDream.Shared/Replay.fs | 107 ++++++++++ .../SomhairlesDream.Shared.fsproj | 12 ++ 4 files changed, 439 insertions(+) create mode 100644 src/SomhairlesDream.Shared/Artifact.fs create mode 100644 src/SomhairlesDream.Shared/Domain.fs create mode 100644 src/SomhairlesDream.Shared/Replay.fs create mode 100644 src/SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj (limited to 'src/SomhairlesDream.Shared') diff --git a/src/SomhairlesDream.Shared/Artifact.fs b/src/SomhairlesDream.Shared/Artifact.fs new file mode 100644 index 0000000..d1cb4f0 --- /dev/null +++ b/src/SomhairlesDream.Shared/Artifact.fs @@ -0,0 +1,218 @@ +namespace SomhairlesDream.Shared + +open System +#if !FABLE_COMPILER +open System.Text.Json +open System.Text.Json.Nodes +#endif + +[] +type RunIds = + { ProjectId: string + RunId: string + BaseId: string + TargetId: string } + +module Ids = + open System.Text.RegularExpressions + + let private resourcePattern = Regex("^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$", RegexOptions.CultureInvariant) + let private objectPattern = Regex("^[a-z0-9]+(?:[._-][a-z0-9]+)*$", RegexOptions.CultureInvariant) + + let validateResourceId value = + if isNull value || not (resourcePattern.IsMatch(value)) then + Error "resource id must contain only lower-case letters, digits, and hyphens" + else + Ok () + + let validateObjectId value = + if isNull value || not (objectPattern.IsMatch(value)) then + Error "object id must use the dotted lower-case namespace" + else + Ok () + +[] +type RunStart = + { Ids: RunIds + At: DateTimeOffset + TotalSteps: int } + +[] +type RunHeartbeat = + { Ids: RunIds + At: DateTimeOffset + Message: string } + +[] +type RunCheckpoint = + { Ids: RunIds + At: DateTimeOffset + StepId: string + StepIndex: int + TotalSteps: int + Message: string + ArtifactPath: string } + +[] +type RunComplete = + { Ids: RunIds + At: DateTimeOffset + ManifestPath: string } + +[] +type RunFail = + { Ids: RunIds + At: DateTimeOffset + StepId: string option + Message: string } + +type RunEvent = + | Start of RunStart + | Heartbeat of RunHeartbeat + | Checkpoint of RunCheckpoint + | Complete of RunComplete + | Fail of RunFail + +[] +type RunEventWire = + { Kind: string + At: DateTimeOffset + ProjectId: string + RunId: string + BaseId: string + TargetId: string + TotalSteps: int option + StepId: string option + StepIndex: int option + Message: string option + ArtifactPath: string option + ManifestPath: string option } + +module RunEvent = + let private ids (value: RunIds) = + value.ProjectId, value.RunId, value.BaseId, value.TargetId + + let toWire event = + let kind, eventIds, at, totalSteps, stepId, stepIndex, message, artifactPath, manifestPath = + match event with + | Start value -> + "start", value.Ids, value.At, Some value.TotalSteps, None, None, None, None, None + | Heartbeat value -> + "heartbeat", value.Ids, value.At, None, None, None, Some value.Message, None, None + | Checkpoint value -> + "checkpoint", + value.Ids, + value.At, + Some value.TotalSteps, + Some value.StepId, + Some value.StepIndex, + Some value.Message, + Some value.ArtifactPath, + None + | Complete value -> + "complete", value.Ids, value.At, None, None, None, None, None, Some value.ManifestPath + | Fail value -> + "fail", value.Ids, value.At, None, value.StepId, None, Some value.Message, None, None + + let projectId, runId, baseId, targetId = ids eventIds + + { Kind = kind + At = at + ProjectId = projectId + RunId = runId + BaseId = baseId + TargetId = targetId + TotalSteps = totalSteps + StepId = stepId + StepIndex = stepIndex + Message = message + ArtifactPath = artifactPath + ManifestPath = manifestPath } + +#if !FABLE_COMPILER + let private addOptional (json: JsonObject) (name: string) value create = + match value with + | Some item -> json[name] <- create item + | None -> () + + let private jsonOptions () = + let options = JsonSerializerOptions() + options.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase + options.WriteIndented <- false + options + + let toJson event = + let wire = toWire event + let json = JsonObject() + json["kind"] <- JsonValue.Create(wire.Kind) + json["at"] <- JsonValue.Create(wire.At) + json["projectId"] <- JsonValue.Create(wire.ProjectId) + json["runId"] <- JsonValue.Create(wire.RunId) + json["baseId"] <- JsonValue.Create(wire.BaseId) + json["targetId"] <- JsonValue.Create(wire.TargetId) + addOptional json "totalSteps" wire.TotalSteps (fun value -> JsonValue.Create(value)) + addOptional json "stepId" wire.StepId (fun value -> JsonValue.Create(value)) + addOptional json "stepIndex" wire.StepIndex (fun value -> JsonValue.Create(value)) + addOptional json "message" wire.Message (fun value -> JsonValue.Create(value)) + addOptional json "artifactPath" wire.ArtifactPath (fun value -> JsonValue.Create(value)) + addOptional json "manifestPath" wire.ManifestPath (fun value -> JsonValue.Create(value)) + json.ToJsonString(jsonOptions()) +#endif + +[] +type ArtifactChange = + { ObjectId: string + Kind: string + Summary: string } + +[] +type RenderArtifact = + { ArtifactPath: string + Sha256: string + Bytes: int64 + ExportedAt: DateTimeOffset } + +[] +type StepArtifact = + { StepId: string + StepIndex: int + ArtifactPath: string + LogPath: string + Sha256: string + Bytes: int64 + ExportedAt: DateTimeOffset + ObjectIds: string array + VerifiedObjectIds: string array + Changes: ArtifactChange array + Render: RenderArtifact option } + +[] +type ArtifactManifest = + { SchemaVersion: int + ProjectId: string + RunId: string + BaseId: string + TargetId: string + Status: string + StartedAt: DateTimeOffset + CompletedAt: DateTimeOffset + BlenderVersion: string + Steps: StepArtifact array } + +[] +type RunSnapshot = + { ProjectId: string + RunId: string + BaseId: string + TargetId: string + Status: string + CurrentStepId: string option + CurrentStepIndex: int + TotalSteps: int + LastHeartbeatAt: DateTimeOffset option + UpdatedAt: DateTimeOffset + Message: string + ManifestPath: string option + ManifestUrl: string option + Error: string option + IsStale: bool } diff --git a/src/SomhairlesDream.Shared/Domain.fs b/src/SomhairlesDream.Shared/Domain.fs new file mode 100644 index 0000000..fd229fa --- /dev/null +++ b/src/SomhairlesDream.Shared/Domain.fs @@ -0,0 +1,102 @@ +namespace SomhairlesDream.Shared + +open System + +[] +type RunStatus = + | Idle = 0 + | Queued = 1 + | Running = 2 + | Succeeded = 3 + | Failed = 4 + | Stale = 5 + +type Vector3 = + { X: float + Y: float + Z: float } + +type MeshSnapshot = + { Version: int + Label: string + Vertices: Vector3 array + Faces: int array array } + +type LiveSnapshot = + { ProjectId: string + RunId: string + Status: RunStatus + CurrentVersion: int + TargetVersion: int + HeartbeatAt: DateTimeOffset option + UpdatedAt: DateTimeOffset + Message: string + Mesh: MeshSnapshot option + NextDesignRunScheduled: bool } + +module MeshData = + let positionData (mesh: MeshSnapshot) = + mesh.Vertices + |> Array.collect (fun vertex -> [| vertex.X; vertex.Y; vertex.Z |]) + + let triangleIndices (mesh: MeshSnapshot) = + mesh.Faces + |> Array.collect (fun face -> + if face.Length < 3 then + [||] + else + [| for index in 1 .. face.Length - 2 do + yield face[0] + yield face[index] + yield face[index + 1] |]) + +module RunState = + let initial projectId runId now = + { ProjectId = projectId + RunId = runId + Status = RunStatus.Idle + CurrentVersion = 0 + TargetVersion = 0 + HeartbeatAt = None + UpdatedAt = now + Message = "等待设计运行" + Mesh = None + NextDesignRunScheduled = false } + + let start now state = + let targetVersion = state.TargetVersion + 1 + + { state with + Status = RunStatus.Running + TargetVersion = targetVersion + HeartbeatAt = Some now + UpdatedAt = now + Message = $"正在生成第 {targetVersion} 版几何" + NextDesignRunScheduled = false } + + let heartbeat now state = + if state.Status = RunStatus.Running then + { state with + HeartbeatAt = Some now + UpdatedAt = now } + else + state + + let statusAt timeout now state = + match state.HeartbeatAt with + | Some heartbeatAt when state.Status = RunStatus.Running && now - heartbeatAt > timeout -> + { state with + Status = RunStatus.Stale + UpdatedAt = now + Message = "心跳超时,等待重连" } + | _ -> state + + let publish now mesh state = + { state with + Status = RunStatus.Succeeded + CurrentVersion = mesh.Version + HeartbeatAt = Some now + UpdatedAt = now + Message = $"已发布{mesh.Label}" + Mesh = Some mesh + NextDesignRunScheduled = false } diff --git a/src/SomhairlesDream.Shared/Replay.fs b/src/SomhairlesDream.Shared/Replay.fs new file mode 100644 index 0000000..782e632 --- /dev/null +++ b/src/SomhairlesDream.Shared/Replay.fs @@ -0,0 +1,107 @@ +namespace SomhairlesDream.Shared + +type ReplayFrame = + { Version: int + Label: string + Mesh: MeshSnapshot } + +module Replay = + let private vertex x y z = + { X = x + Y = y + Z = z } + + let private frame version label vertices faces = + { Version = version + Label = label + Mesh = + { Version = version + Label = label + Vertices = vertices + Faces = faces } } + + let frames () = + [| frame + 1 + "重建起点" + [| vertex -2.0 0.0 -1.0 + vertex 2.0 0.0 -1.0 + vertex 2.0 0.0 1.0 + vertex -2.0 0.0 1.0 + vertex -1.0 2.0 -0.5 + vertex 1.0 2.0 -0.5 + vertex 1.0 2.0 0.5 + vertex -1.0 2.0 0.5 |] + [| [| 0; 1; 2 |] + [| 0; 2; 3 |] + [| 4; 6; 5 |] + [| 4; 7; 6 |] + [| 0; 4; 5 |] + [| 0; 5; 1 |] + [| 1; 5; 6 |] + [| 1; 6; 2 |] + [| 2; 6; 7 |] + [| 2; 7; 3 |] + [| 3; 7; 4 |] + [| 3; 4; 0 |] |] + frame + 2 + "主承力骨架" + [| vertex -2.5 0.0 -1.0 + vertex 2.5 0.0 -1.0 + vertex 2.5 0.0 1.0 + vertex -2.5 0.0 1.0 + vertex -1.5 2.2 -0.7 + vertex 1.5 2.2 -0.7 + vertex 1.5 2.2 0.7 + vertex -1.5 2.2 0.7 + vertex -0.45 0.0 -0.45 + vertex 0.45 0.0 -0.45 + vertex 0.45 3.0 0.45 + vertex -0.45 3.0 0.45 |] + [| [| 0; 1; 2 |] + [| 0; 2; 3 |] + [| 4; 6; 5 |] + [| 4; 7; 6 |] + [| 8; 9; 10 |] + [| 8; 10; 11 |] + [| 0; 4; 8 |] + [| 4; 11; 8 |] + [| 1; 9; 5 |] + [| 1; 5; 2 |] + [| 2; 5; 10 |] + [| 2; 10; 3 |] + [| 3; 10; 7 |] + [| 3; 7; 0 |] |] + frame + 3 + "舱段重构" + [| vertex -3.0 0.0 -1.3 + vertex 3.0 0.0 -1.3 + vertex 3.0 0.0 1.3 + vertex -3.0 0.0 1.3 + vertex -2.0 2.0 -1.0 + vertex 2.0 2.0 -1.0 + vertex 2.0 2.0 1.0 + vertex -2.0 2.0 1.0 + vertex -1.0 3.4 -0.65 + vertex 1.0 3.4 -0.65 + vertex 1.0 3.4 0.65 + vertex -1.0 3.4 0.65 + vertex -3.8 0.6 0.0 + vertex 3.8 0.6 0.0 |] + [| [| 0; 1; 2 |] + [| 0; 2; 3 |] + [| 4; 6; 5 |] + [| 4; 7; 6 |] + [| 8; 9; 10 |] + [| 8; 10; 11 |] + [| 0; 4; 8 |] + [| 0; 8; 12 |] + [| 1; 13; 5 |] + [| 1; 2; 13 |] + [| 2; 6; 10 |] + [| 2; 10; 13 |] + [| 3; 11; 7 |] + [| 3; 12; 11 |] + [| 3; 0; 12 |] |] |] diff --git a/src/SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj b/src/SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj new file mode 100644 index 0000000..7a7e853 --- /dev/null +++ b/src/SomhairlesDream.Shared/SomhairlesDream.Shared.fsproj @@ -0,0 +1,12 @@ + + + net8.0 + SomhairlesDream.Shared + SomhairlesDream.Shared + + + + + + + -- cgit v1.2.3