;; Title: Miniforge.ai
;; Subtitle: An agentic SDLC / fleet-control platform
;; Author: Christopher Lester
;; Line: Founder, Miniforge.ai (project)
;; Copyright 2025-2026 Christopher Lester (christopher@miniforge.ai)
;;
;; Licensed under the Apache License, Version 2.0 (the "License");
;; you may not use this file except in compliance with the License.
;; You may obtain a copy of the License at
;;
;;     http://www.apache.org/licenses/LICENSE-2.0
;;
;; Unless required by applicable law or agreed to in writing, software
;; distributed under the License is distributed on an "AS IS" BASIS,
;; WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
;; See the License for the specific language governing permissions and
;; limitations under the License.

(ns ai.miniforge.repo-dag.core
  "Implementation of the repo-dag component.
   Layer 0: Pure functions for DAG operations
   Layer 1: Protocol definition
   Layer 2: In-memory implementation"
  (:require
   [clojure.set :as set]
   [clojure.string :as str]
   [malli.core :as m]
   [malli.error :as me]
   [ai.miniforge.anomaly.interface :as anomaly]
   [ai.miniforge.dag-primitives.interface :as dag]
   [ai.miniforge.repo-dag.schema :as schema]))

;------------------------------------------------------------------------------ Layer 0
;; Pure functions for DAG operations

(defn validate-schema-anomaly
  "Validate a value against a schema. Return the value on success, or an
   `:invalid-input` anomaly map describing the validation failure.

   The anomaly's `:anomaly/data` carries:
   - `:schema` — the schema definition that rejected the value
   - `:value`  — the offending value
   - `:errors` — humanized malli error map

   Prefer this over [[validate-schema]] in non-boundary code."
  [schema-def value]
  (if (m/validate schema-def value)
    value
    (anomaly/anomaly :invalid-input
                     "Schema validation failed"
                     {:schema schema-def
                      :value value
                      :errors (me/humanize (m/explain schema-def value))})))

(defn validate-schema
  "Validate a value against a schema, returning the value or throwing.

   DEPRECATED: prefer [[validate-schema-anomaly]], which returns an anomaly
   map rather than throwing. Retained for backward compatibility with
   existing callers that rely on the ex-info shape."
  {:deprecated "exceptions-as-data — prefer validate-schema-anomaly"}
  [schema-def value]
  (let [result (validate-schema-anomaly schema-def value)]
    (if (anomaly/anomaly? result)
      (throw (ex-info "Schema validation failed"
                      {:schema schema-def
                       :value value
                       :errors (get-in result [:anomaly/data :errors])}))
      result)))

(defn- build-repo-node
  "Internal: assemble the repo-node map (no validation). Shared between
   the throwing and anomaly-returning constructors so the two never drift."
  [{:keys [repo/url repo/name repo/org repo/type repo/layer repo/default-branch repo/watch-config]
    :or {default-branch "main"}}]
  (let [inferred-layer (or layer (schema/infer-layer type))]
    (cond-> {:repo/url url
             :repo/name name
             :repo/type type
             :repo/layer inferred-layer
             :repo/default-branch default-branch}
      org (assoc :repo/org org)
      watch-config (assoc :repo/watch-config watch-config))))

(defn make-repo-node-anomaly
  "Build a repo node from `repo-config`, returning the validated node or
   an `:invalid-input` anomaly when schema validation rejects it (e.g.
   missing `:repo/type`, malformed `:repo/url`, …).

   Prefer this over [[make-repo-node]] in non-boundary code."
  [repo-config]
  (validate-schema-anomaly schema/RepoNode (build-repo-node repo-config)))

(defn make-repo-node
  "Create a repo node with layer inference if not specified.

   DEPRECATED: prefer [[make-repo-node-anomaly]], which returns an anomaly
   map rather than throwing on schema validation failure."
  {:deprecated "exceptions-as-data — prefer make-repo-node-anomaly"}
  [repo-config]
  (validate-schema schema/RepoNode (build-repo-node repo-config)))

(defn- build-repo-edge
  "Internal: assemble the edge map (no validation). Shared between the
   throwing and anomaly-returning constructors."
  [{:keys [edge/from edge/to edge/constraint edge/merge-ordering edge/validation]
    :or {merge-ordering :sequential}}]
  (cond-> {:edge/from from
           :edge/to to
           :edge/constraint constraint
           :edge/merge-ordering merge-ordering}
    validation (assoc :edge/validation validation)))

(defn make-repo-edge-anomaly
  "Build a repo edge from `edge-config`, returning the validated edge or
   an `:invalid-input` anomaly when schema validation rejects it (unknown
   `:edge/constraint`, unknown `:edge/merge-ordering`, missing endpoints, …).

   Prefer this over [[make-repo-edge]] in non-boundary code."
  [edge-config]
  (validate-schema-anomaly schema/RepoEdge (build-repo-edge edge-config)))

(defn make-repo-edge
  "Create a repo edge with default merge ordering.

   DEPRECATED: prefer [[make-repo-edge-anomaly]]."
  {:deprecated "exceptions-as-data — prefer make-repo-edge-anomaly"}
  [edge-config]
  (validate-schema schema/RepoEdge (build-repo-edge edge-config)))

(defn make-dag
  "Create a new empty DAG."
  [id dag-name description]
  (let [dag (cond-> {:dag/id id
                     :dag/name dag-name
                     :dag/repos []
                     :dag/edges []}
              description (assoc :dag/description description))]
    (validate-schema schema/RepoDag dag)))

(defn find-repo-by-name
  "Find a repo node by name in the DAG."
  [dag repo-name]
  (some #(when (= repo-name (:repo/name %)) %) (:dag/repos dag)))

(defn find-edge
  "Find an edge by from/to pair."
  [dag from-repo to-repo]
  (some #(when (and (= from-repo (:edge/from %))
                    (= to-repo (:edge/to %)))
           %)
        (:dag/edges dag)))

(defn repo-names
  "Get set of all repo names in the DAG."
  [dag]
  (set (map :repo/name (:dag/repos dag))))

;------------------------------------------------------------------------------ Layer 0.5
;; Kahn's algorithm for topological sort

(defn topo-sort
  "Perform topological sort using Kahn's algorithm.
   Returns {:success true :order [...]} or {:success false :error :cycle-detected :cycle-nodes #{...}}"
  [dag]
  (let [;; Use insertion order from :dag/repos for deterministic tie-breaking.
        node-order (mapv :repo/name (:dag/repos dag))
        edges      (:dag/edges dag)
        dep-map    (reduce (fn [m edge]
                             (update m (:edge/to edge) (fnil conj #{}) (:edge/from edge)))
                           (zipmap node-order (repeat #{}))
                           edges)
        result     (dag/topological-sort dep-map node-order)]
    (if (dag/ok? result)
      {:success true :order (:data result)}
      {:success     false
       :error       :cycle-detected
       :cycle-nodes (get-in result [:error :data :cycle-nodes])})))

(defn find-cycle-nodes
  "Find nodes involved in a cycle. Returns nil if no cycle."
  [dag]
  (let [result (topo-sort dag)]
    (when-not (:success result)
      (:cycle-nodes result))))

;------------------------------------------------------------------------------ Layer 0.6
;; Graph traversal functions

(defn build-adjacency
  "Build adjacency list from DAG edges."
  [dag]
  (reduce (fn [acc edge]
            (update acc (:edge/from edge) (fnil conj #{}) (:edge/to edge)))
          {}
          (:dag/edges dag)))

(defn build-reverse-adjacency
  "Build reverse adjacency list (for finding upstream repos)."
  [dag]
  (reduce (fn [acc edge]
            (update acc (:edge/to edge) (fnil conj #{}) (:edge/from edge)))
          {}
          (:dag/edges dag)))

(defn downstream-repos
  "Get all repos that depend on the given repo (transitive closure)."
  [dag repo-name]
  (let [adj (build-adjacency dag)]
    (loop [to-visit #{repo-name}
           visited #{}]
      (if (empty? to-visit)
        (disj visited repo-name) ; Don't include the starting repo
        (let [current (first to-visit)
              neighbors (get adj current #{})]
          (recur (into (disj to-visit current)
                       (set/difference neighbors visited))
                 (conj visited current)))))))

(defn upstream-repos-impl
  "Get all repos that this repo depends on (transitive closure)."
  [dag repo-name]
  (let [rev-adj (build-reverse-adjacency dag)]
    (loop [to-visit #{repo-name}
           visited #{}]
      (if (empty? to-visit)
        (disj visited repo-name) ; Don't include the starting repo
        (let [current (first to-visit)
              neighbors (get rev-adj current #{})]
          (recur (into (disj to-visit current)
                       (set/difference neighbors visited))
                 (conj visited current)))))))

(defn compute-merge-order
  "Given a set of repo names, compute valid merge order based on DAG topology.
   Only considers repos in the pr-set."
  [dag pr-set]
  (let [pr-names (set pr-set)
        ;; Filter edges to only those between repos in pr-set
        relevant-edges (filterv (fn [edge]
                                  (and (contains? pr-names (:edge/from edge))
                                       (contains? pr-names (:edge/to edge))))
                                (:dag/edges dag))
        ;; Create sub-DAG
        sub-dag (assoc dag
                       :dag/repos (filterv #(contains? pr-names (:repo/name %))
                                           (:dag/repos dag))
                       :dag/edges relevant-edges)]
    (topo-sort sub-dag)))

(defn compute-layers
  "Group repos by their layer."
  [dag]
  (reduce (fn [acc repo]
            (let [rname (:repo/name repo)
                  rlayer (:repo/layer repo)]
              (update acc rlayer (fnil conj []) rname)))
          {}
          (:dag/repos dag)))

;------------------------------------------------------------------------------ Layer 0.7
;; Validation functions

(defn validate-dag-impl
  "Validate DAG structure. Returns {:valid? bool :errors [...]}"
  [dag]
  (let [repo-name-set (repo-names dag)
        errors (atom [])]
    ;; Check for duplicate repo names
    (let [names (map :repo/name (:dag/repos dag))
          freqs (frequencies names)
          dups (filterv #(> (val %) 1) freqs)]
      (doseq [[dup-name dup-count] dups]
        (swap! errors conj {:type :duplicate-repo
                            :message (str "Duplicate repo name: " dup-name " (appears " dup-count " times)")
                            :data {:repo-name dup-name :count dup-count}})))

    ;; Check for edges referencing missing repos
    (doseq [edge (:dag/edges dag)]
      (let [from-repo (:edge/from edge)
            to-repo (:edge/to edge)]
        (when-not (contains? repo-name-set from-repo)
          (swap! errors conj {:type :missing-repo
                              :message (str "Edge references missing repo: " from-repo)
                              :data {:edge-from from-repo :edge-to to-repo}}))
        (when-not (contains? repo-name-set to-repo)
          (swap! errors conj {:type :missing-repo
                              :message (str "Edge references missing repo: " to-repo)
                              :data {:edge-from from-repo :edge-to to-repo}}))))

    ;; Check for self-loops
    (doseq [edge (:dag/edges dag)]
      (let [from-repo (:edge/from edge)
            to-repo (:edge/to edge)]
        (when (= from-repo to-repo)
          (swap! errors conj {:type :self-loop
                              :message (str "Self-loop detected: " from-repo " -> " to-repo)
                              :data {:repo from-repo}}))))

    ;; Check for cycles
    (let [result (topo-sort dag)]
      (when-not (:success result)
        (swap! errors conj {:type :cycle
                            :message (str "Cycle detected involving repos: "
                                          (str/join ", " (:cycle-nodes result)))
                            :data {:cycle-nodes (:cycle-nodes result)}})))

    {:valid? (empty? @errors)
     :errors @errors}))

;------------------------------------------------------------------------------ Layer 0.8
;; Anomaly-returning shape helpers
;;
;; These are private store-shaped helpers; they consume the manager's
;; mutable atom directly so the protocol method bodies stay thin and
;; the anomaly-returning siblings have a single, shared implementation.

(defn- dag-not-found-anomaly
  "Construct the canonical `:not-found` anomaly for a missing DAG."
  [dag-id]
  (anomaly/anomaly :not-found
                   "DAG not found"
                   {:dag-id dag-id}))

(defn- add-repo-impl
  "Anomaly-returning add-repo. `store` is the manager's atom. Returns the
   updated DAG on success, or an anomaly on missing DAG (`:not-found`),
   schema-invalid `repo-config` (`:invalid-input`), or duplicate repo name
   (`:conflict`)."
  [store dag-id repo-config]
  (if-let [dag (get @store dag-id)]
    (let [node-or-anomaly (make-repo-node-anomaly repo-config)]
      (if (anomaly/anomaly? node-or-anomaly)
        node-or-anomaly
        (let [rname (:repo/name node-or-anomaly)]
          (if (find-repo-by-name dag rname)
            (anomaly/anomaly :conflict
                             "Repo already exists"
                             {:dag-id dag-id :repo-name rname})
            (let [updated (update dag :dag/repos conj node-or-anomaly)]
              (swap! store assoc dag-id updated)
              updated)))))
    (dag-not-found-anomaly dag-id)))

(defn- remove-repo-impl
  "Anomaly-returning remove-repo. Returns the updated DAG on success, or a
   `:not-found` anomaly if the DAG does not exist."
  [store dag-id repo-name]
  (if-let [dag (get @store dag-id)]
    (let [updated-repos (filterv #(not= repo-name (:repo/name %)) (:dag/repos dag))
          updated-edges (filterv #(and (not= repo-name (:edge/from %))
                                       (not= repo-name (:edge/to %)))
                                 (:dag/edges dag))
          updated (assoc dag
                         :dag/repos updated-repos
                         :dag/edges updated-edges)]
      (swap! store assoc dag-id updated)
      updated)
    (dag-not-found-anomaly dag-id)))

(defn- add-edge-impl
  "Anomaly-returning add-edge. Returns the updated DAG on success, or an
   anomaly classifying the failure (:not-found, :invalid-input, or :conflict)."
  [store dag-id from-repo to-repo constraint merge-ordering]
  (if-let [dag (get @store dag-id)]
    (let [repo-name-set (repo-names dag)]
      (cond
        (not (contains? repo-name-set from-repo))
        (anomaly/anomaly :not-found
                         "From repo not found in DAG"
                         {:dag-id dag-id :repo-name from-repo})

        (not (contains? repo-name-set to-repo))
        (anomaly/anomaly :not-found
                         "To repo not found in DAG"
                         {:dag-id dag-id :repo-name to-repo})

        (= from-repo to-repo)
        (anomaly/anomaly :invalid-input
                         "Self-loop not allowed"
                         {:dag-id dag-id :repo-name from-repo})

        (find-edge dag from-repo to-repo)
        (anomaly/anomaly :conflict
                         "Edge already exists"
                         {:dag-id dag-id :from from-repo :to to-repo})

        :else
        (let [edge-or-anomaly (make-repo-edge-anomaly
                               {:edge/from from-repo
                                :edge/to to-repo
                                :edge/constraint constraint
                                :edge/merge-ordering merge-ordering})]
          (if (anomaly/anomaly? edge-or-anomaly)
            edge-or-anomaly
            (let [updated (update dag :dag/edges conj edge-or-anomaly)
                  result (topo-sort updated)]
              (if (:success result)
                (do
                  (swap! store assoc dag-id updated)
                  updated)
                (anomaly/anomaly :conflict
                                 "Adding edge would create cycle"
                                 {:dag-id dag-id
                                  :from from-repo
                                  :to to-repo
                                  :cycle-nodes (:cycle-nodes result)})))))))
    (dag-not-found-anomaly dag-id)))

(defn- remove-edge-impl
  "Anomaly-returning remove-edge. Returns the updated DAG on success, or a
   `:not-found` anomaly if the DAG does not exist."
  [store dag-id from-repo to-repo]
  (if-let [dag (get @store dag-id)]
    (let [updated-edges (filterv #(not (and (= from-repo (:edge/from %))
                                            (= to-repo (:edge/to %))))
                                 (:dag/edges dag))
          updated (assoc dag :dag/edges updated-edges)]
      (swap! store assoc dag-id updated)
      updated)
    (dag-not-found-anomaly dag-id)))

(defn- with-dag-or-anomaly
  "Look up `dag-id` in `store`; on hit, apply `f` to the DAG and return its
   result. On miss, return a `:not-found` anomaly. Shared helper for the
   read-only protocol methods."
  [store dag-id f]
  (if-let [dag (get @store dag-id)]
    (f dag)
    (dag-not-found-anomaly dag-id)))

(defn- anomaly->ex-info
  "Translate a repo-dag anomaly back into an ex-info for backward-compat
   throwing wrappers. Preserves the anomaly's `:anomaly/data` as ex-data."
  [a]
  (ex-info (:anomaly/message a) (:anomaly/data a)))

;------------------------------------------------------------------------------ Layer 1
;; Protocol definition

(defprotocol RepoDagManager
  "Protocol for managing repository dependency graphs."

  ;; CRUD operations
  (create-dag [this dag-name description]
    "Create a new empty DAG. Returns the created DAG.")

  (add-repo [this dag-id repo-config]
    "Add a repository node to the DAG. Returns updated DAG.")

  (add-repo-anomaly [this dag-id repo-config]
    "Anomaly-returning sibling of `add-repo`. Returns the updated DAG or
     an anomaly map on failure.")

  (remove-repo [this dag-id repo-name]
    "Remove a repository from the DAG (and its edges). Returns updated DAG.")

  (remove-repo-anomaly [this dag-id repo-name]
    "Anomaly-returning sibling of `remove-repo`. Returns the updated DAG
     or a `:not-found` anomaly when the DAG is missing.")

  (add-edge [this dag-id from-repo to-repo constraint merge-ordering]
    "Add a dependency edge between repos. Returns updated DAG or error if invalid.")

  (add-edge-anomaly [this dag-id from-repo to-repo constraint merge-ordering]
    "Anomaly-returning sibling of `add-edge`. Returns the updated DAG or
     an anomaly map on failure.")

  (remove-edge [this dag-id from-repo to-repo]
    "Remove a dependency edge. Returns updated DAG.")

  (remove-edge-anomaly [this dag-id from-repo to-repo]
    "Anomaly-returning sibling of `remove-edge`. Returns the updated DAG
     or a `:not-found` anomaly when the DAG is missing.")

  ;; Queries
  (get-dag [this dag-id]
    "Retrieve a DAG by ID. Returns nil if not found.")

  (compute-topo-order [this dag-id]
    "Compute topological sort of repos. Returns {:success true :order [...]} or error.")

  (compute-topo-order-anomaly [this dag-id]
    "Anomaly-returning sibling of `compute-topo-order`. Returns the topo
     result or a `:not-found` anomaly when the DAG is missing.")

  (affected-repos [this dag-id changed-repo]
    "Given a changed repo, return all downstream repos that may be affected.")

  (affected-repos-anomaly [this dag-id changed-repo]
    "Anomaly-returning sibling of `affected-repos`. Returns the downstream
     repo set or a `:not-found` anomaly when the DAG is missing.")

  (upstream-repos [this dag-id repo-name]
    "Return all repos that this repo depends on.")

  (upstream-repos-anomaly [this dag-id repo-name]
    "Anomaly-returning sibling of `upstream-repos`. Returns the upstream
     repo set or a `:not-found` anomaly when the DAG is missing.")

  (merge-order [this dag-id pr-set]
    "Given a set of PRs across repos, return valid merge order.")

  (merge-order-anomaly [this dag-id pr-set]
    "Anomaly-returning sibling of `merge-order`. Returns the merge-order
     result or a `:not-found` anomaly when the DAG is missing.")

  ;; Validation
  (validate-dag [this dag-id]
    "Check for cycles, orphans, invalid references. Returns {:valid? bool :errors [...]}.")

  (validate-dag-anomaly [this dag-id]
    "Anomaly-returning sibling of `validate-dag`. Returns the validation
     result or a `:not-found` anomaly when the DAG is missing."))

;------------------------------------------------------------------------------ Layer 2
;; In-memory implementation

(defrecord InMemoryDagManager [store]
  RepoDagManager

  (create-dag [_this dag-name description]
    (let [id (random-uuid)
          dag (make-dag id dag-name description)]
      (swap! store assoc id dag)
      dag))

  (add-repo-anomaly [_this dag-id repo-config]
    (add-repo-impl store dag-id repo-config))

  (add-repo [this dag-id repo-config]
    (let [result (add-repo-anomaly this dag-id repo-config)]
      (if (anomaly/anomaly? result)
        (throw (anomaly->ex-info result))
        result)))

  (remove-repo-anomaly [_this dag-id repo-name]
    (remove-repo-impl store dag-id repo-name))

  (remove-repo [this dag-id repo-name]
    (let [result (remove-repo-anomaly this dag-id repo-name)]
      (if (anomaly/anomaly? result)
        (throw (anomaly->ex-info result))
        result)))

  (add-edge-anomaly [_this dag-id from-repo to-repo constraint merge-ordering]
    (add-edge-impl store dag-id from-repo to-repo constraint merge-ordering))

  (add-edge [this dag-id from-repo to-repo constraint merge-ordering]
    (let [result (add-edge-anomaly this dag-id from-repo to-repo constraint merge-ordering)]
      (if (anomaly/anomaly? result)
        (throw (anomaly->ex-info result))
        result)))

  (remove-edge-anomaly [_this dag-id from-repo to-repo]
    (remove-edge-impl store dag-id from-repo to-repo))

  (remove-edge [this dag-id from-repo to-repo]
    (let [result (remove-edge-anomaly this dag-id from-repo to-repo)]
      (if (anomaly/anomaly? result)
        (throw (anomaly->ex-info result))
        result)))

  (get-dag [_this dag-id]
    (get @store dag-id))

  (compute-topo-order-anomaly [_this dag-id]
    (with-dag-or-anomaly store dag-id topo-sort))

  (compute-topo-order [this dag-id]
    (let [result (compute-topo-order-anomaly this dag-id)]
      (if (anomaly/anomaly? result)
        (throw (anomaly->ex-info result))
        result)))

  (affected-repos-anomaly [_this dag-id changed-repo]
    (with-dag-or-anomaly store dag-id #(downstream-repos % changed-repo)))

  (affected-repos [this dag-id changed-repo]
    (let [result (affected-repos-anomaly this dag-id changed-repo)]
      (if (anomaly/anomaly? result)
        (throw (anomaly->ex-info result))
        result)))

  (upstream-repos-anomaly [_this dag-id repo-name]
    (with-dag-or-anomaly store dag-id #(upstream-repos-impl % repo-name)))

  (upstream-repos [this dag-id repo-name]
    (let [result (upstream-repos-anomaly this dag-id repo-name)]
      (if (anomaly/anomaly? result)
        (throw (anomaly->ex-info result))
        result)))

  (merge-order-anomaly [_this dag-id pr-set]
    (with-dag-or-anomaly store dag-id #(compute-merge-order % pr-set)))

  (merge-order [this dag-id pr-set]
    (let [result (merge-order-anomaly this dag-id pr-set)]
      (if (anomaly/anomaly? result)
        (throw (anomaly->ex-info result))
        result)))

  (validate-dag-anomaly [_this dag-id]
    (with-dag-or-anomaly store dag-id validate-dag-impl))

  (validate-dag [this dag-id]
    (let [result (validate-dag-anomaly this dag-id)]
      (if (anomaly/anomaly? result)
        (throw (anomaly->ex-info result))
        result))))

;------------------------------------------------------------------------------ Layer 3
;; Factory functions

(defn create-manager
  "Create a new in-memory DAG manager."
  []
  (->InMemoryDagManager (atom {})))

(defn get-all-dags
  "Get all DAGs from the manager's store."
  [manager]
  (vals @(:store manager)))

(defn reset-manager!
  "Reset the manager's store. Useful for testing."
  [manager]
  (reset! (:store manager) {}))

;------------------------------------------------------------------------------ Rich Comment
(comment
  ;; Create a manager
  (def mgr (create-manager))

  ;; Create a DAG
  (def dag (create-dag mgr "kiddom-infra" "Kiddom infrastructure repos"))

  ;; Add repos
  (add-repo mgr (:dag/id dag)
            {:repo/url "https://github.com/kiddom/terraform-modules"
             :repo/name "terraform-modules"
             :repo/type :terraform-module})

  (add-repo mgr (:dag/id dag)
            {:repo/url "https://github.com/kiddom/terraform"
             :repo/name "terraform-live"
             :repo/type :terraform-live})

  (add-repo mgr (:dag/id dag)
            {:repo/url "https://github.com/kiddom/k8s-manifests"
             :repo/name "k8s-manifests"
             :repo/type :kubernetes})

  ;; Add edges
  (add-edge mgr (:dag/id dag)
            "terraform-modules" "terraform-live"
            :module-before-live :sequential)

  (add-edge mgr (:dag/id dag)
            "terraform-live" "k8s-manifests"
            :infra-before-k8s :sequential)

  ;; Compute topo order
  (compute-topo-order mgr (:dag/id dag))
  ;; => {:success true, :order ["terraform-modules" "terraform-live" "k8s-manifests"]}

  ;; Get affected repos
  (affected-repos mgr (:dag/id dag) "terraform-modules")
  ;; => #{"terraform-live" "k8s-manifests"}

  ;; Get upstream repos
  (upstream-repos mgr (:dag/id dag) "k8s-manifests")
  ;; => #{"terraform-modules" "terraform-live"}

  ;; Validate
  (validate-dag mgr (:dag/id dag))
  ;; => {:valid? true, :errors []}

  ;; Try to create a cycle
  (try
    (add-edge mgr (:dag/id dag)
              "k8s-manifests" "terraform-modules"
              :library-before-consumer :sequential)
    (catch Exception e
      (ex-data e)))
  ;; => {:dag-id ..., :from "k8s-manifests", :to "terraform-modules", :cycle-nodes #{"terraform-modules" "terraform-live" "k8s-manifests"}}

  ;; Anomaly-returning sibling
  (add-edge-anomaly mgr (:dag/id dag)
                    "k8s-manifests" "terraform-modules"
                    :library-before-consumer :sequential)
  ;; => {:anomaly/type :conflict ...}

  :leave-this-here)
