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 +++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 src/SomhairlesDream.Shared/Artifact.fs (limited to 'src/SomhairlesDream.Shared/Artifact.fs') 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 } -- cgit v1.2.3