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 = 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 = 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 = 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