gglib_core/domain/council/
task_graph.rs

1//! Task graph types for the Director/Worker orchestrator.
2//!
3//! A [`TaskGraph`] is a directed acyclic graph (DAG) of [`TaskNode`]s that the
4//! orchestrator executor drives to completion in topological order.  Nodes with
5//! no unsatisfied dependencies run concurrently; each node executes as an
6//! isolated `AgentLoop` worker with its own tool allowlist and context window.
7//!
8//! # Validation
9//!
10//! Call [`TaskGraph::new`] (preferred) or [`TaskGraph::validate_acyclic`]
11//! (on a manually-constructed graph) before handing a graph to the executor.
12//! Both methods check:
13//!
14//! - All `depends_on` ids resolve to existing nodes.
15//! - The dependency edges form a true DAG (no cycles, no self-loops).
16//! - Node count ≤ [`MAX_NODES`].
17//! - Longest path depth ≤ [`MAX_DEPTH`].
18//!
19//! # Example
20//!
21//! ```rust
22//! use std::collections::HashSet;
23//! use gglib_core::domain::council::task_graph::{
24//!     TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode,
25//! };
26//!
27//! let nodes = vec![
28//!     TaskNode {
29//!         id: NodeId("research".into()),
30//!         goal: "Research the topic".into(),
31//!         depends_on: vec![],
32//!         tool_allowlist: vec!["web_search".into()],
33//!         kind: TaskNodeKind::Leaf,
34//!         role: None,
35//!         status: NodeStatus::Pending,
36//!         output: None,
37//!         compacted_output: None,
38//!         error: None,
39//!     },
40//!     TaskNode {
41//!         id: NodeId("draft".into()),
42//!         goal: "Write the first draft".into(),
43//!         depends_on: vec![NodeId("research".into())],
44//!         tool_allowlist: vec![],
45//!         kind: TaskNodeKind::Leaf,
46//!         role: None,
47//!         status: NodeStatus::Pending,
48//!         output: None,
49//!         compacted_output: None,
50//!         error: None,
51//!     },
52//! ];
53//! let graph = TaskGraph::new("Write a research doc".into(), HitlMode::None, nodes).unwrap();
54//! let ready = graph.ready_nodes(&HashSet::new());
55//! assert_eq!(ready.len(), 1);
56//! assert_eq!(ready[0], &NodeId("research".into()));
57//! ```
58
59use std::collections::{HashMap, HashSet};
60
61use serde::{Deserialize, Serialize};
62use thiserror::Error;
63
64use crate::domain::agent::ToolDefinition;
65use crate::domain::council::role_catalog::RoleId;
66
67// =============================================================================
68// Size limits
69// =============================================================================
70
71/// Maximum number of nodes a [`TaskGraph`] may contain **per subgraph**.
72///
73/// This limit applies independently to each nested subgraph (including the
74/// top-level graph and every [`TaskNodeKind::Team`] subgraph).  It does **not**
75/// constrain the aggregate node count across all subgraphs; see
76/// [`MAX_TOTAL_NODES`] for the soft aggregate budget (Phase J).
77///
78/// Keeping each subgraph small ensures that no single LLM planning call ever
79/// needs to reason about more than ~8 sibling nodes.
80pub const MAX_NODES: usize = 8;
81
82/// Maximum depth (longest root-to-leaf path) a [`TaskGraph`] may have **per subgraph**.
83///
84/// Applied independently inside each subgraph.  Keeps total latency bounded
85/// even when nodes are forced to run serially within a department.
86pub const MAX_DEPTH: usize = 3;
87
88/// Soft upper bound on the **total** node count across all subgraphs combined.
89///
90/// This constant is not enforced during validation in Phase G.  Starting from
91/// Phase J the executor consults `CouncilConfig::max_total_nodes` (which
92/// defaults to this value) and emits a warn-only cost-estimate event when the
93/// aggregate budget is exceeded.
94pub const MAX_TOTAL_NODES: usize = 32;
95
96// =============================================================================
97// NodeId
98// =============================================================================
99
100/// Opaque node identifier within a [`TaskGraph`].
101///
102/// Short, human-readable strings are recommended (e.g. `"research"`, `"draft"`,
103/// `"review"`).  Uniqueness within a graph is enforced by [`TaskGraph::new`].
104#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
105pub struct NodeId(pub String);
106
107impl std::fmt::Display for NodeId {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        f.write_str(&self.0)
110    }
111}
112
113// =============================================================================
114// NodeStatus
115// =============================================================================
116
117/// Lifecycle state of a single [`TaskNode`].
118///
119/// State transitions:
120/// ```text
121/// Pending → AwaitingApproval → Running → Compacting → Done
122///                                      ↘ Failed
123/// Pending → Skipped   (upstream failure)
124/// ```
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum NodeStatus {
128    /// Not yet eligible to run (one or more predecessors are incomplete).
129    Pending,
130    /// All predecessors are done but human approval is required before
131    /// execution begins ([`HitlMode::ApproveEachNode`] or higher).
132    AwaitingApproval,
133    /// Currently executing (the worker `AgentLoop` is running).
134    Running,
135    /// Execution finished; the output is being compacted for downstream use.
136    Compacting,
137    /// Execution and compaction finished successfully.
138    Done,
139    /// Execution failed with an unrecoverable error.
140    Failed,
141    /// Skipped because an upstream node failed and no path to this node is
142    /// viable.
143    Skipped,
144}
145
146// =============================================================================
147// HitlMode
148// =============================================================================
149
150/// Controls when the orchestrator executor pauses to request human approval.
151///
152/// Variants are ordered from least to most restrictive; each variant implies
153/// all approvals required by lower variants.
154#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
155#[serde(rename_all = "snake_case")]
156pub enum HitlMode {
157    /// Execute the graph without any human-in-the-loop gates.
158    #[default]
159    None,
160    /// Pause once after the plan is produced so the user can review (and
161    /// optionally edit) the full [`TaskGraph`] before execution begins.
162    ApprovePlan,
163    /// Pause before each node executes (implies `ApprovePlan`).
164    ///
165    /// Use this when the goal is sensitive and each step must be vetted
166    /// individually.
167    ApproveEachNode,
168    /// Pause before each individual tool call a worker makes
169    /// (implies `ApproveEachNode`).
170    ///
171    /// This is the most restrictive mode; it is primarily useful for
172    /// debugging or for high-stakes automation contexts.
173    ApproveTools,
174}
175
176// =============================================================================
177// NodeBudget
178// =============================================================================
179
180/// Advisory upper bound on the aggregate node count across all subgraphs.
181///
182/// The Phase J cost estimator checks the total node count against
183/// `upper_bound()` and emits a [`tracing::warn!`] when exceeded, but never
184/// returns an error — the executor always proceeds.
185///
186/// # Default
187///
188/// [`NodeBudget::TaskForce`] (up to 25 nodes).
189///
190/// # Example
191///
192/// ```rust
193/// use gglib_core::domain::council::task_graph::NodeBudget;
194///
195/// let budget = NodeBudget::TaskForce;
196/// assert_eq!(budget.upper_bound(), 25);
197///
198/// let custom = NodeBudget::Custom { value: 100 };
199/// assert_eq!(custom.upper_bound(), 100);
200/// ```
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
202#[serde(tag = "kind", rename_all = "snake_case")]
203pub enum NodeBudget {
204    /// Up to 3 nodes — single-agent equivalent.
205    Solo,
206    /// Up to 8 nodes — small coordination team.
207    SmallTeam,
208    /// Up to 25 nodes — cross-functional task force. **Default**.
209    #[default]
210    TaskForce,
211    /// Up to 60 nodes — full department-scale run.
212    Department,
213    /// Caller-specified upper bound.
214    Custom {
215        /// The maximum number of aggregate nodes allowed before a warning is
216        /// emitted.
217        value: usize,
218    },
219}
220
221impl NodeBudget {
222    /// Returns the advisory upper bound for this budget variant.
223    pub const fn upper_bound(&self) -> usize {
224        match self {
225            Self::Solo => 3,
226            Self::SmallTeam => 8,
227            Self::TaskForce => 25,
228            Self::Department => 60,
229            Self::Custom { value } => *value,
230        }
231    }
232}
233
234// =============================================================================
235// DebateConfig (Phase N)
236// =============================================================================
237
238/// An individual participant in a [`TaskNodeKind::Debate`] node.
239///
240/// Each agent runs in every debate round as an independent LLM call with its
241/// own `persona` and `perspective` injected into the system prompt.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct DebateAgent {
244    /// Short unique id within this debate config (e.g. `"optimist"`).
245    pub id: String,
246    /// Display name shown in the frontend stream and logs.
247    pub name: String,
248    /// Hex colour code (`#rrggbb`) used for this agent's text in the UI.
249    pub color: String,
250    /// System-prompt fragment that establishes the agent's character.
251    pub persona: String,
252    /// The specific position or viewpoint the agent is instructed to argue.
253    pub perspective: String,
254    /// Float in `[0.0, 1.0]`.  Mapped to LLM temperature: `0.0` = highly
255    /// focused / convergent, `1.0` = exploratory / contentious.
256    pub contentiousness: f32,
257    /// Optional tool-call filter expression.  If `None`, inherits the parent
258    /// node's `tool_allowlist`.
259    #[serde(skip_serializing_if = "Option::is_none", default)]
260    pub tool_filter: Option<String>,
261}
262
263/// Configuration for the optional judge that assesses each debate round.
264///
265/// The judge is a separate LLM call that reads the full round transcript and
266/// decides whether consensus has been reached or whether the debate should
267/// continue for more rounds.  When no judge is configured all [`DebateConfig::rounds`]
268/// are always executed.
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct DebateJudgeConfig {
271    /// The judge will not attempt early stopping before this round index
272    /// (1-based).  `1` means the judge may recommend a stop after the very
273    /// first round.  Defaults to `1`.
274    #[serde(default = "default_min_rounds_before_stop")]
275    pub min_rounds_before_stop: u32,
276}
277
278const fn default_min_rounds_before_stop() -> u32 {
279    1
280}
281
282/// Complete configuration for a [`TaskNodeKind::Debate`] node.
283///
284/// Embedded directly in the node, analogous to [`TaskNodeKind::Team`]'s
285/// `subgraph` field.  The Director populates this when it chooses the
286/// `debate` kind; the user may edit it in the [`PlanEditor`] before approval.
287///
288/// [`PlanEditor`]: https://github.com/mmogr/gglib/tree/epic/orchestrator/src/components/Council/PlanEditor
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct DebateConfig {
291    /// The agents that participate in each round.  The Director caps this at
292    /// 4; the frontend plan editor enforces ≤ 4 via UI constraints.
293    pub agents: Vec<DebateAgent>,
294    /// Number of rounds to run (minimum 1).  The Director caps this at 3;
295    /// the frontend plan editor allows up to 5.
296    pub rounds: u32,
297    /// Optional judge configuration.  When `None` no early-stop check is
298    /// performed and all `rounds` are always executed.
299    #[serde(skip_serializing_if = "Option::is_none", default)]
300    pub judge: Option<DebateJudgeConfig>,
301    /// Optional additional instruction passed to the synthesis LLM after all
302    /// rounds complete.  Useful for domain-specific framing of the verdict.
303    #[serde(skip_serializing_if = "Option::is_none", default)]
304    pub synthesis_guidance: Option<String>,
305}
306
307// =============================================================================
308// TaskNodeKind
309// =============================================================================
310
311/// Execution kind for a [`TaskNode`]: leaf worker, nested sub-team, or multi-agent debate.
312///
313/// A leaf runs a single agent; a team executes its own nested [`TaskGraph`];
314/// a debate node runs multiple agents arguing a contested goal across rounds.
315///
316/// Existing (Phase A–F) nodes all default to `Leaf` when deserialized from
317/// JSON that does not carry a `kind` field.
318///
319/// # Example
320///
321/// ```rust
322/// use gglib_core::domain::council::task_graph::TaskNodeKind;
323///
324/// // Missing `kind` in old JSON → defaults to `Leaf`.
325/// let kind: TaskNodeKind = serde_json::from_str("\"leaf\"").unwrap();
326/// assert!(matches!(kind, TaskNodeKind::Leaf));
327/// ```
328#[derive(Debug, Clone, Serialize, Deserialize, Default)]
329#[serde(rename_all = "snake_case")]
330pub enum TaskNodeKind {
331    /// A standard single-worker node.  This is the default for all v1 nodes
332    /// and for any node deserialized from JSON that omits the `kind` field.
333    #[default]
334    Leaf,
335    /// A compound node that encapsulates a nested [`TaskGraph`] (a sub-team).
336    ///
337    /// The executor recurses into `subgraph` and runs it to completion before
338    /// marking this node done and passing its synthesised output downstream.
339    Team {
340        /// The nested task graph executed when this node runs.
341        subgraph: Box<TaskGraph>,
342    },
343    /// A debate node where multiple agents argue a contested goal across
344    /// multiple rounds, with an optional judge and final synthesis.
345    ///
346    /// The executor runs the debate engine to completion before marking this
347    /// node done.  The synthesis output becomes the node's `output` and is
348    /// compacted for downstream predecessors exactly like a leaf output.
349    Debate {
350        /// Full debate configuration (participants, rounds, judge).
351        config: DebateConfig,
352    },
353}
354
355// =============================================================================
356// TaskNode
357// =============================================================================
358
359/// A single work unit in a [`TaskGraph`].
360///
361/// Each node is either a [`TaskNodeKind::Leaf`] (executed as an isolated
362/// `AgentLoop` worker) or a [`TaskNodeKind::Team`] (a sub-team with its own
363/// nested [`TaskGraph`] that the executor recurses into).
364///
365/// Leaf node context is assembled from:
366///
367/// 1. Its own `goal` as the system instruction.
368/// 2. Compacted outputs from each `depends_on` predecessor as additional
369///    context messages.
370/// 3. No orchestrator planning history (strict isolation between nodes).
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct TaskNode {
373    /// Short, unique identifier for this node within its graph.
374    pub id: NodeId,
375    /// One-sentence goal that the worker agent is asked to achieve.
376    pub goal: String,
377    /// Nodes whose outputs this node depends on.
378    ///
379    /// Must form a DAG when combined with all other nodes' `depends_on` lists.
380    pub depends_on: Vec<NodeId>,
381    /// Tool names the worker is permitted to call.
382    ///
383    /// An empty list means no tools are available to this worker.  Names must
384    /// match entries in the runtime tool catalog.
385    pub tool_allowlist: Vec<String>,
386    /// Whether this node is a leaf worker or a sub-team.
387    ///
388    /// Defaults to [`TaskNodeKind::Leaf`] when absent in serialised JSON so
389    /// that all Phase A–F plans deserialise without modification.
390    #[serde(default)]
391    pub kind: TaskNodeKind,
392    /// Optional specialist role assigned to this node.
393    ///
394    /// When `Some`, the executor will inject the role's
395    /// `system_prompt_fragment` before the node's `goal`.  `None` means the
396    /// node runs as a generic worker.
397    #[serde(skip_serializing_if = "Option::is_none", default)]
398    pub role: Option<RoleId>,
399    /// Current lifecycle state (mutated by the executor as the node runs).
400    pub status: NodeStatus,
401    /// The worker's full output text (set after reaching [`NodeStatus::Done`]).
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub output: Option<String>,
404    /// Compressed summary of `output` passed to downstream nodes as context.
405    ///
406    /// Set by the compaction step immediately after execution finishes.
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub compacted_output: Option<String>,
409    /// Error message if the node reached [`NodeStatus::Failed`].
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub error: Option<String>,
412}
413
414// =============================================================================
415// TaskGraphError
416// =============================================================================
417
418/// Error variants produced during [`TaskGraph`] construction or validation.
419#[derive(Debug, Clone, PartialEq, Eq, Error)]
420pub enum TaskGraphError {
421    /// Two nodes share the same [`NodeId`].
422    #[error("duplicate node id: {0}")]
423    DuplicateNodeId(String),
424
425    /// A `depends_on` entry references a [`NodeId`] not present in the graph.
426    #[error("node '{node}' depends on unknown id '{dep}'")]
427    UnknownDependency {
428        /// The node that declared the bad dependency.
429        node: String,
430        /// The referenced id that does not exist.
431        dep: String,
432    },
433
434    /// The dependency edges contain at least one cycle.
435    #[error("cycle detected involving node '{0}'")]
436    Cycle(String),
437
438    /// The graph exceeds [`MAX_NODES`].
439    #[error("graph has {count} nodes; maximum is {max}")]
440    TooManyNodes {
441        /// Actual node count.
442        count: usize,
443        /// The limit ([`MAX_NODES`]).
444        max: usize,
445    },
446
447    /// The longest root-to-leaf path exceeds [`MAX_DEPTH`].
448    #[error("graph depth {depth} exceeds maximum {max}")]
449    DepthExceeded {
450        /// Computed depth.
451        depth: usize,
452        /// The limit ([`MAX_DEPTH`]).
453        max: usize,
454    },
455
456    /// A node lists a tool not present in the provided catalog.
457    #[error("node '{node}' requires unknown tool '{tool}'")]
458    UnknownTool {
459        /// The node with the invalid allowlist entry.
460        node: String,
461        /// The tool name that was not found in the catalog.
462        tool: String,
463    },
464
465    /// A referenced node id was not found in the graph.
466    #[error("node not found: {0}")]
467    NodeNotFound(String),
468
469    /// The diff is structurally invalid (e.g. would leave an empty graph).
470    #[error("invalid diff: {0}")]
471    DiffInvalid(String),
472}
473
474// =============================================================================
475// GraphDiff
476// =============================================================================
477
478/// A single mutation to apply to a [`TaskGraph`] at runtime.
479///
480/// Produced by the steering LLM (`gglib_agent::council::steering`) and
481/// applied via [`TaskGraph::apply_diff`].  Each variant mutates the graph and
482/// then triggers re-validation to ensure the graph stays well-formed.
483///
484/// The `#[non_exhaustive]` attribute allows new variants to be added in future
485/// minor versions without breaking match arms in external crates.
486#[derive(Debug, Clone, Serialize, Deserialize)]
487#[serde(tag = "op", rename_all = "snake_case")]
488#[non_exhaustive]
489pub enum GraphDiff {
490    /// Add a new node.
491    ///
492    /// The node's `depends_on` carries all its incoming edges.
493    AddNode {
494        /// Node to insert.
495        node: TaskNode,
496    },
497
498    /// Remove a node and strip all edges that pointed to it.
499    ///
500    /// Any node whose `depends_on` contains `id` will have that entry removed.
501    RemoveNode {
502        /// Id of the node to remove.
503        id: NodeId,
504    },
505
506    /// Replace one node with multiple nodes.
507    ///
508    /// The original is removed; each entry in `into` is inserted.  Any node
509    /// that depended on the original will be updated to depend on all
510    /// replacements.
511    SplitNode {
512        /// Id of the node to replace.
513        id: NodeId,
514        /// Replacement nodes (at least one entry required).
515        into: Vec<TaskNode>,
516    },
517
518    /// Redirect one dependency edge.
519    ///
520    /// In `node_id`'s `depends_on` list, replaces `old_dep` with `new_dep`.
521    RerouteEdge {
522        /// Node whose dependency is being changed.
523        node_id: NodeId,
524        /// Dependency to remove.
525        old_dep: NodeId,
526        /// Dependency to add in its place.
527        new_dep: NodeId,
528    },
529
530    /// Set or clear a node's specialist role.
531    SetRole {
532        /// Target node id.
533        id: NodeId,
534        /// New role (`None` clears the current role).
535        role: Option<RoleId>,
536    },
537
538    /// Replace a node's entire tool allowlist.
539    SetTools {
540        /// Target node id.
541        id: NodeId,
542        /// New tool allowlist (replaces the current list entirely).
543        tool_allowlist: Vec<String>,
544    },
545
546    /// Wrap a set of nodes into a new [`TaskNodeKind::Team`] node.
547    ///
548    /// The wrapped nodes become the team's subgraph.  The `team_id` node
549    /// inherits all external in-edges (from nodes NOT in `ids`) and any
550    /// node outside `ids` that depended on a wrapped node will be updated
551    /// to depend on `team_id` instead.
552    WrapInTeam {
553        /// Ids of the nodes to wrap (all must exist in the graph).
554        ids: Vec<NodeId>,
555        /// Unique id for the new `Team` wrapper node.
556        team_id: NodeId,
557        /// High-level goal text for the new `Team` node.
558        team_goal: String,
559    },
560}
561
562// =============================================================================
563// Internal DFS helpers
564// =============================================================================
565
566const WHITE: u8 = 0; // not yet visited
567const GRAY: u8 = 1; // currently on the DFS stack
568const BLACK: u8 = 2; // fully processed
569
570fn dfs_check_cycle<'a>(
571    id: &'a NodeId,
572    nodes: &'a HashMap<NodeId, TaskNode>,
573    color: &mut HashMap<&'a NodeId, u8>,
574) -> Result<(), TaskGraphError> {
575    color.insert(id, GRAY);
576    let node = &nodes[id];
577    for dep in &node.depends_on {
578        let dep_color = color.get(dep).copied().unwrap_or(WHITE);
579        match dep_color {
580            GRAY => return Err(TaskGraphError::Cycle(dep.0.clone())),
581            BLACK => {} // already fully explored
582            _ => dfs_check_cycle(dep, nodes, color)?,
583        }
584    }
585    color.insert(id, BLACK);
586    Ok(())
587}
588
589fn compute_depth(
590    id: &NodeId,
591    nodes: &HashMap<NodeId, TaskNode>,
592    memo: &mut HashMap<NodeId, usize>,
593) -> usize {
594    if let Some(&d) = memo.get(id) {
595        return d;
596    }
597    let depth = nodes[id]
598        .depends_on
599        .iter()
600        .map(|dep| compute_depth(dep, nodes, memo) + 1)
601        .max()
602        .unwrap_or(0);
603    memo.insert(id.clone(), depth);
604    depth
605}
606
607// =============================================================================
608// TaskGraph
609// =============================================================================
610
611/// A validated directed acyclic graph of [`TaskNode`]s.
612///
613/// Produced by the director agent via [`crate::domain::council::events::CouncilEvent::PlanProposed`]
614/// and executed by the orchestrator runner in topological order.
615///
616/// # Construction
617///
618/// Prefer [`TaskGraph::new`] over direct struct construction to get automatic
619/// validation.  Deserializing from a director's JSON response should be
620/// followed by [`TaskGraph::validate_acyclic`] to re-check invariants.
621///
622/// # Concurrency model
623///
624/// The executor calls [`TaskGraph::ready_nodes`] after each node completes to
625/// discover newly-eligible nodes, which it launches concurrently.
626///
627/// # Example
628///
629/// ```rust
630/// use gglib_core::domain::council::task_graph::{TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode};
631///
632/// let nodes = vec![TaskNode {
633///     id: NodeId("only".into()),
634///     goal: "Do the thing".into(),
635///     depends_on: vec![],
636///     tool_allowlist: vec![],
637///     kind: TaskNodeKind::Leaf,
638///     role: None,
639///     status: NodeStatus::Pending,
640///     output: None,
641///     compacted_output: None,
642///     error: None,
643/// }];
644/// let g = TaskGraph::new("My goal".into(), HitlMode::None, nodes).unwrap();
645/// assert_eq!(g.roots().len(), 1);
646/// ```
647#[derive(Debug, Clone, Serialize, Deserialize)]
648pub struct TaskGraph {
649    /// The high-level goal the director is trying to achieve.
650    pub goal: String,
651    /// Human-in-the-loop approval policy for this execution run.
652    pub hitl_mode: HitlMode,
653    /// All work units, keyed by their unique [`NodeId`].
654    pub nodes: HashMap<NodeId, TaskNode>,
655}
656
657impl TaskGraph {
658    /// Construct and validate a [`TaskGraph`] from a flat list of nodes.
659    ///
660    /// Returns an error if the node list violates any structural invariant
661    /// (duplicate ids, unknown dependencies, cycles, size/depth limits).
662    ///
663    /// # Example
664    ///
665    /// ```rust
666    /// use gglib_core::domain::council::task_graph::{
667    ///     TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode,
668    /// };
669    ///
670    /// let nodes = vec![TaskNode {
671    ///     id: NodeId("only".into()),
672    ///     goal: "Do the thing".into(),
673    ///     depends_on: vec![],
674    ///     tool_allowlist: vec![],
675    ///     kind: TaskNodeKind::Leaf,
676    ///     role: None,
677    ///     status: NodeStatus::Pending,
678    ///     output: None,
679    ///     compacted_output: None,
680    ///     error: None,
681    /// }];
682    /// let g = TaskGraph::new("Top goal".into(), HitlMode::None, nodes);
683    /// assert!(g.is_ok());
684    /// ```
685    pub fn new(
686        goal: String,
687        hitl_mode: HitlMode,
688        nodes: Vec<TaskNode>,
689    ) -> Result<Self, TaskGraphError> {
690        if nodes.len() > MAX_NODES {
691            return Err(TaskGraphError::TooManyNodes {
692                count: nodes.len(),
693                max: MAX_NODES,
694            });
695        }
696
697        let mut map: HashMap<NodeId, TaskNode> = HashMap::with_capacity(nodes.len());
698        for node in nodes {
699            if map.contains_key(&node.id) {
700                return Err(TaskGraphError::DuplicateNodeId(node.id.0));
701            }
702            map.insert(node.id.clone(), node);
703        }
704
705        let graph = Self {
706            goal,
707            hitl_mode,
708            nodes: map,
709        };
710        graph.validate_acyclic()?;
711        Ok(graph)
712    }
713
714    /// Validate that the dependency graph contains no cycles.
715    ///
716    /// Uses DFS colouring (white → gray → black).  Also validates that all
717    /// `depends_on` ids resolve to existing nodes and that depth ≤ [`MAX_DEPTH`].
718    ///
719    /// # Errors
720    ///
721    /// Returns the first structural violation found.
722    ///
723    /// # Example — detecting a cycle
724    ///
725    /// ```rust
726    /// use std::collections::HashMap;
727    /// use gglib_core::domain::council::task_graph::{
728    ///     TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode, TaskGraphError,
729    /// };
730    ///
731    /// let mut nodes = HashMap::new();
732    /// for (id, dep) in [("a", "b"), ("b", "a")] {
733    ///     nodes.insert(NodeId(id.into()), TaskNode {
734    ///         id: NodeId(id.into()),
735    ///         goal: id.into(),
736    ///         depends_on: vec![NodeId(dep.into())],
737    ///         tool_allowlist: vec![],
738    ///         kind: TaskNodeKind::Leaf,
739    ///         role: None,
740    ///         status: NodeStatus::Pending,
741    ///         output: None, compacted_output: None, error: None,
742    ///     });
743    /// }
744    /// let g = TaskGraph { goal: "cyclic".into(), hitl_mode: HitlMode::None, nodes };
745    /// assert!(matches!(g.validate_acyclic(), Err(TaskGraphError::Cycle(_))));
746    /// ```
747    pub fn validate_acyclic(&self) -> Result<(), TaskGraphError> {
748        // Check all depends_on ids resolve.
749        for (id, node) in &self.nodes {
750            for dep in &node.depends_on {
751                if !self.nodes.contains_key(dep) {
752                    return Err(TaskGraphError::UnknownDependency {
753                        node: id.0.clone(),
754                        dep: dep.0.clone(),
755                    });
756                }
757            }
758        }
759
760        // DFS cycle detection.
761        let mut color: HashMap<&NodeId, u8> = HashMap::new();
762        for id in self.nodes.keys() {
763            if color.get(id).copied().unwrap_or(WHITE) == WHITE {
764                dfs_check_cycle(id, &self.nodes, &mut color)?;
765            }
766        }
767
768        // Depth check.
769        let mut memo: HashMap<NodeId, usize> = HashMap::new();
770        for id in self.nodes.keys() {
771            let depth = compute_depth(id, &self.nodes, &mut memo);
772            if depth > MAX_DEPTH {
773                return Err(TaskGraphError::DepthExceeded {
774                    depth,
775                    max: MAX_DEPTH,
776                });
777            }
778        }
779
780        // Recurse into Team subgraphs.  Per-subgraph caps (MAX_NODES, MAX_DEPTH)
781        // are enforced independently inside each subgraph by the recursive call.
782        // Cycles spanning a team boundary surface here as errors from the
783        // nested validate_acyclic call with the offending node id reported.
784        for node in self.nodes.values() {
785            if let TaskNodeKind::Team { subgraph } = &node.kind {
786                subgraph.validate_acyclic()?;
787            }
788        }
789
790        Ok(())
791    }
792
793    /// Validate that every tool name in every node's `tool_allowlist` exists
794    /// in `catalog`.
795    ///
796    /// Call this after [`TaskGraph::validate_acyclic`] when the runtime tool
797    /// catalog is available.
798    ///
799    /// # Example
800    ///
801    /// ```rust
802    /// use gglib_core::domain::council::task_graph::{
803    ///     TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode, TaskGraphError,
804    /// };
805    /// use gglib_core::ToolDefinition;
806    ///
807    /// let catalog = vec![ToolDefinition {
808    ///     name: "web_search".into(),
809    ///     description: None,
810    ///     input_schema: None,
811    ///     title: None,
812    /// }];
813    /// let nodes = vec![TaskNode {
814    ///     id: NodeId("r".into()),
815    ///     goal: "research".into(),
816    ///     depends_on: vec![],
817    ///     tool_allowlist: vec!["nonexistent".into()],
818    ///     kind: TaskNodeKind::Leaf,
819    ///     role: None,
820    ///     status: NodeStatus::Pending,
821    ///     output: None, compacted_output: None, error: None,
822    /// }];
823    /// let g = TaskGraph::new("goal".into(), HitlMode::None, nodes).unwrap();
824    /// assert!(matches!(
825    ///     g.validate_tool_allowlist(&catalog),
826    ///     Err(TaskGraphError::UnknownTool { .. })
827    /// ));
828    /// ```
829    pub fn validate_tool_allowlist(
830        &self,
831        catalog: &[ToolDefinition],
832    ) -> Result<(), TaskGraphError> {
833        // Build two lookup sets: one for exact qualified names, one for bare
834        // names (stripping any "{server-id}:" or "builtin:" prefix).  This
835        // lets the Director emit bare names like "browser_navigate" in
836        // tool_allowlist entries while the catalog contains qualified names
837        // like "2:browser_navigate".
838        let known_exact: HashSet<&str> = catalog.iter().map(|t| t.name.as_str()).collect();
839        let known_bare: HashSet<&str> = catalog
840            .iter()
841            .map(|t| {
842                t.name
843                    .find(':')
844                    .map_or(t.name.as_str(), |pos| &t.name[pos + 1..])
845            })
846            .collect();
847        for (id, node) in &self.nodes {
848            for tool in &node.tool_allowlist {
849                if !known_exact.contains(tool.as_str()) && !known_bare.contains(tool.as_str()) {
850                    return Err(TaskGraphError::UnknownTool {
851                        node: id.0.clone(),
852                        tool: tool.clone(),
853                    });
854                }
855            }
856        }
857        Ok(())
858    }
859
860    /// Return the ids of root nodes — those with an empty `depends_on` list.
861    ///
862    /// # Example
863    ///
864    /// ```rust
865    /// use gglib_core::domain::council::task_graph::{
866    ///     TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode,
867    /// };
868    ///
869    /// let nodes = vec![
870    ///     TaskNode { id: NodeId("root".into()), goal: "g".into(), depends_on: vec![],
871    ///                tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
872    ///                status: NodeStatus::Pending,
873    ///                output: None, compacted_output: None, error: None },
874    ///     TaskNode { id: NodeId("child".into()), goal: "g".into(),
875    ///                depends_on: vec![NodeId("root".into())],
876    ///                tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
877    ///                status: NodeStatus::Pending,
878    ///                output: None, compacted_output: None, error: None },
879    /// ];
880    /// let g = TaskGraph::new("goal".into(), HitlMode::None, nodes).unwrap();
881    /// assert_eq!(g.roots().len(), 1);
882    /// assert_eq!(g.roots()[0], &NodeId("root".into()));
883    /// ```
884    pub fn roots(&self) -> Vec<&NodeId> {
885        let mut roots: Vec<&NodeId> = self
886            .nodes
887            .keys()
888            .filter(|id| self.nodes[*id].depends_on.is_empty())
889            .collect();
890        // Sort for deterministic ordering.
891        roots.sort_by(|a, b| a.0.cmp(&b.0));
892        roots
893    }
894
895    /// Return the ids of nodes eligible to run given the set of
896    /// already-completed nodes.
897    ///
898    /// A node is eligible when:
899    /// - It is **not** in `completed`.
900    /// - All of its `depends_on` predecessors **are** in `completed`.
901    ///
902    /// The returned slice is sorted by node id for deterministic ordering.
903    ///
904    /// # Example
905    ///
906    /// ```rust
907    /// use std::collections::HashSet;
908    /// use gglib_core::domain::council::task_graph::{
909    ///     TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode,
910    /// };
911    ///
912    /// let nodes = vec![
913    ///     TaskNode { id: NodeId("a".into()), goal: "g".into(), depends_on: vec![],
914    ///                tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
915    ///                status: NodeStatus::Pending,
916    ///                output: None, compacted_output: None, error: None },
917    ///     TaskNode { id: NodeId("b".into()), goal: "g".into(),
918    ///                depends_on: vec![NodeId("a".into())],
919    ///                tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
920    ///                status: NodeStatus::Pending,
921    ///                output: None, compacted_output: None, error: None },
922    /// ];
923    /// let g = TaskGraph::new("goal".into(), HitlMode::None, nodes).unwrap();
924    ///
925    /// // Nothing completed — only the root is ready.
926    /// let ready = g.ready_nodes(&HashSet::new());
927    /// assert_eq!(ready.len(), 1);
928    /// assert_eq!(ready[0], &NodeId("a".into()));
929    ///
930    /// // "a" completed — "b" is now ready.
931    /// let done: HashSet<NodeId> = [NodeId("a".into())].into();
932    /// let ready = g.ready_nodes(&done);
933    /// assert_eq!(ready.len(), 1);
934    /// assert_eq!(ready[0], &NodeId("b".into()));
935    /// ```
936    pub fn ready_nodes(&self, completed: &HashSet<NodeId>) -> Vec<&NodeId> {
937        let mut ready: Vec<&NodeId> = self
938            .nodes
939            .keys()
940            .filter(|id| {
941                !completed.contains(*id)
942                    && self.nodes[*id]
943                        .depends_on
944                        .iter()
945                        .all(|dep| completed.contains(dep))
946            })
947            .collect();
948        // Sort for deterministic ordering.
949        ready.sort_by(|a, b| a.0.cmp(&b.0));
950        ready
951    }
952
953    /// Return the total number of nodes across all subgraphs combined.
954    ///
955    /// Each [`TaskNodeKind::Leaf`] contributes 1.  Each [`TaskNodeKind::Team`]
956    /// contributes 1 (for itself) plus the recursive count of its subgraph.
957    ///
958    /// This is the aggregate tally consulted by the warn-only token-cost
959    /// estimator introduced in Phase J.  Validation never fails on this value
960    /// in Phase G; see [`MAX_TOTAL_NODES`] for the soft budget.
961    ///
962    /// # Example
963    ///
964    /// ```rust
965    /// use gglib_core::domain::council::task_graph::{
966    ///     TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode,
967    /// };
968    ///
969    /// let nodes = vec![
970    ///     TaskNode { id: NodeId("a".into()), goal: "g".into(), depends_on: vec![],
971    ///                tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
972    ///                status: NodeStatus::Pending,
973    ///                output: None, compacted_output: None, error: None },
974    ///     TaskNode { id: NodeId("b".into()), goal: "g".into(),
975    ///                depends_on: vec![NodeId("a".into())],
976    ///                tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
977    ///                status: NodeStatus::Pending,
978    ///                output: None, compacted_output: None, error: None },
979    /// ];
980    /// let g = TaskGraph::new("goal".into(), HitlMode::None, nodes).unwrap();
981    /// assert_eq!(g.total_node_count(), 2);
982    /// ```
983    pub fn total_node_count(&self) -> usize {
984        self.nodes
985            .values()
986            .map(|n| match &n.kind {
987                TaskNodeKind::Leaf | TaskNodeKind::Debate { .. } => 1,
988                TaskNodeKind::Team { subgraph } => 1 + subgraph.total_node_count(),
989            })
990            .sum()
991    }
992
993    /// Apply a [`GraphDiff`] mutation in-place, then re-validate.
994    ///
995    /// If either the mutation itself or the subsequent [`validate_acyclic`]
996    /// call fails, the graph is restored to its pre-call state (clone-and-swap
997    /// rollback) and the error is returned.
998    ///
999    /// # Errors
1000    ///
1001    /// Returns the first [`TaskGraphError`] encountered (pre-condition checks
1002    /// or post-mutation validation).
1003    ///
1004    /// # Example
1005    ///
1006    /// ```rust
1007    /// use gglib_core::domain::council::task_graph::{
1008    ///     TaskGraph, TaskNode, TaskNodeKind, NodeId, NodeStatus, HitlMode,
1009    ///     GraphDiff,
1010    /// };
1011    ///
1012    /// let nodes = vec![TaskNode {
1013    ///     id: NodeId("a".into()), goal: "a".into(), depends_on: vec![],
1014    ///     tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
1015    ///     status: NodeStatus::Pending, output: None,
1016    ///     compacted_output: None, error: None,
1017    /// }];
1018    /// let mut g = TaskGraph::new("goal".into(), HitlMode::None, nodes).unwrap();
1019    ///
1020    /// let new_node = TaskNode {
1021    ///     id: NodeId("b".into()), goal: "b".into(),
1022    ///     depends_on: vec![NodeId("a".into())],
1023    ///     tool_allowlist: vec![], kind: TaskNodeKind::Leaf, role: None,
1024    ///     status: NodeStatus::Pending, output: None,
1025    ///     compacted_output: None, error: None,
1026    /// };
1027    /// g.apply_diff(&GraphDiff::AddNode { node: new_node }).unwrap();
1028    /// assert_eq!(g.nodes.len(), 2);
1029    /// ```
1030    pub fn apply_diff(&mut self, diff: &GraphDiff) -> Result<(), TaskGraphError> {
1031        let saved = self.clone();
1032        match self.apply_diff_inner(diff) {
1033            Ok(()) => match self.validate_acyclic() {
1034                Ok(()) => Ok(()),
1035                Err(e) => {
1036                    *self = saved;
1037                    Err(e)
1038                }
1039            },
1040            Err(e) => {
1041                *self = saved;
1042                Err(e)
1043            }
1044        }
1045    }
1046
1047    #[allow(clippy::too_many_lines)]
1048    fn apply_diff_inner(&mut self, diff: &GraphDiff) -> Result<(), TaskGraphError> {
1049        match diff {
1050            GraphDiff::AddNode { node } => {
1051                if self.nodes.contains_key(&node.id) {
1052                    return Err(TaskGraphError::DuplicateNodeId(node.id.0.clone()));
1053                }
1054                if self.nodes.len() >= MAX_NODES {
1055                    return Err(TaskGraphError::TooManyNodes {
1056                        count: self.nodes.len() + 1,
1057                        max: MAX_NODES,
1058                    });
1059                }
1060                self.nodes.insert(node.id.clone(), node.clone());
1061            }
1062
1063            GraphDiff::RemoveNode { id } => {
1064                if !self.nodes.contains_key(id) {
1065                    return Err(TaskGraphError::NodeNotFound(id.0.clone()));
1066                }
1067                if self.nodes.len() == 1 {
1068                    return Err(TaskGraphError::DiffInvalid(
1069                        "cannot remove the only node in a graph".into(),
1070                    ));
1071                }
1072                self.nodes.remove(id);
1073                for node in self.nodes.values_mut() {
1074                    node.depends_on.retain(|dep| dep != id);
1075                }
1076            }
1077
1078            GraphDiff::SplitNode { id, into } => {
1079                if !self.nodes.contains_key(id) {
1080                    return Err(TaskGraphError::NodeNotFound(id.0.clone()));
1081                }
1082                if into.is_empty() {
1083                    return Err(TaskGraphError::DiffInvalid(
1084                        "split_node: replacement list must not be empty".into(),
1085                    ));
1086                }
1087                // Check for id conflicts among replacements (excluding the original id).
1088                for n in into {
1089                    if &n.id != id && self.nodes.contains_key(&n.id) {
1090                        return Err(TaskGraphError::DuplicateNodeId(n.id.0.clone()));
1091                    }
1092                }
1093                let new_count = self.nodes.len() - 1 + into.len();
1094                if new_count > MAX_NODES {
1095                    return Err(TaskGraphError::TooManyNodes {
1096                        count: new_count,
1097                        max: MAX_NODES,
1098                    });
1099                }
1100                let replacement_ids: Vec<NodeId> = into.iter().map(|n| n.id.clone()).collect();
1101                self.nodes.remove(id);
1102                for n in into {
1103                    self.nodes.insert(n.id.clone(), n.clone());
1104                }
1105                // Repoint any node that depended on the split node.
1106                for node in self.nodes.values_mut() {
1107                    if node.depends_on.contains(id) {
1108                        node.depends_on.retain(|dep| dep != id);
1109                        for rid in &replacement_ids {
1110                            if !node.depends_on.contains(rid) {
1111                                node.depends_on.push(rid.clone());
1112                            }
1113                        }
1114                    }
1115                }
1116            }
1117
1118            GraphDiff::RerouteEdge {
1119                node_id,
1120                old_dep,
1121                new_dep,
1122            } => {
1123                if !self.nodes.contains_key(node_id) {
1124                    return Err(TaskGraphError::NodeNotFound(node_id.0.clone()));
1125                }
1126                if !self.nodes.contains_key(new_dep) {
1127                    return Err(TaskGraphError::NodeNotFound(new_dep.0.clone()));
1128                }
1129                let node = self.nodes.get_mut(node_id).expect("checked above");
1130                let pos = node
1131                    .depends_on
1132                    .iter()
1133                    .position(|d| d == old_dep)
1134                    .ok_or_else(|| {
1135                        TaskGraphError::DiffInvalid(format!(
1136                            "node '{}' does not depend on '{}'",
1137                            node_id.0, old_dep.0
1138                        ))
1139                    })?;
1140                node.depends_on[pos] = new_dep.clone();
1141            }
1142
1143            GraphDiff::SetRole { id, role } => {
1144                let node = self
1145                    .nodes
1146                    .get_mut(id)
1147                    .ok_or_else(|| TaskGraphError::NodeNotFound(id.0.clone()))?;
1148                node.role.clone_from(role);
1149            }
1150
1151            GraphDiff::SetTools { id, tool_allowlist } => {
1152                let node = self
1153                    .nodes
1154                    .get_mut(id)
1155                    .ok_or_else(|| TaskGraphError::NodeNotFound(id.0.clone()))?;
1156                node.tool_allowlist.clone_from(tool_allowlist);
1157            }
1158
1159            GraphDiff::WrapInTeam {
1160                ids,
1161                team_id,
1162                team_goal,
1163            } => {
1164                if ids.is_empty() {
1165                    return Err(TaskGraphError::DiffInvalid(
1166                        "wrap_in_team: ids list must not be empty".into(),
1167                    ));
1168                }
1169                if self.nodes.contains_key(team_id) {
1170                    return Err(TaskGraphError::DuplicateNodeId(team_id.0.clone()));
1171                }
1172                for id in ids {
1173                    if !self.nodes.contains_key(id) {
1174                        return Err(TaskGraphError::NodeNotFound(id.0.clone()));
1175                    }
1176                }
1177
1178                let wrapped_set: HashSet<NodeId> = ids.iter().cloned().collect();
1179
1180                // Team node inherits all external deps of the wrapped nodes.
1181                let mut team_deps: Vec<NodeId> = Vec::new();
1182                for id in ids {
1183                    for dep in &self.nodes[id].depends_on {
1184                        if !wrapped_set.contains(dep) && !team_deps.contains(dep) {
1185                            team_deps.push(dep.clone());
1186                        }
1187                    }
1188                }
1189
1190                // Extract wrapped nodes into the subgraph (stripping external deps).
1191                let mut sub_nodes: HashMap<NodeId, TaskNode> = HashMap::new();
1192                for id in ids {
1193                    let mut node = self.nodes.remove(id).expect("checked above");
1194                    node.depends_on.retain(|dep| wrapped_set.contains(dep));
1195                    sub_nodes.insert(id.clone(), node);
1196                }
1197
1198                // Repoint external nodes that depended on any wrapped node.
1199                for node in self.nodes.values_mut() {
1200                    let had_dep = node.depends_on.iter().any(|d| wrapped_set.contains(d));
1201                    if had_dep {
1202                        node.depends_on.retain(|d| !wrapped_set.contains(d));
1203                        if !node.depends_on.contains(team_id) {
1204                            node.depends_on.push(team_id.clone());
1205                        }
1206                    }
1207                }
1208
1209                // Size guard for the parent graph (after removals, before insertion).
1210                if self.nodes.len() >= MAX_NODES {
1211                    return Err(TaskGraphError::TooManyNodes {
1212                        count: self.nodes.len() + 1,
1213                        max: MAX_NODES,
1214                    });
1215                }
1216
1217                let subgraph = Self {
1218                    goal: team_goal.clone(),
1219                    hitl_mode: self.hitl_mode.clone(),
1220                    nodes: sub_nodes,
1221                };
1222
1223                let team_node = TaskNode {
1224                    id: team_id.clone(),
1225                    goal: team_goal.clone(),
1226                    depends_on: team_deps,
1227                    tool_allowlist: vec![],
1228                    kind: TaskNodeKind::Team {
1229                        subgraph: Box::new(subgraph),
1230                    },
1231                    role: None,
1232                    status: NodeStatus::Pending,
1233                    output: None,
1234                    compacted_output: None,
1235                    error: None,
1236                };
1237                self.nodes.insert(team_id.clone(), team_node);
1238            }
1239        }
1240        Ok(())
1241    }
1242}
1243
1244// =============================================================================
1245// Unit tests
1246// =============================================================================
1247
1248#[cfg(test)]
1249mod tests {
1250    use std::collections::HashSet;
1251
1252    use super::*;
1253
1254    // ------------------------------------------------------------------
1255    // Helpers
1256    // ------------------------------------------------------------------
1257
1258    fn leaf(id: &str) -> TaskNode {
1259        TaskNode {
1260            id: NodeId(id.into()),
1261            goal: id.into(),
1262            depends_on: vec![],
1263            tool_allowlist: vec![],
1264            kind: TaskNodeKind::Leaf,
1265            role: None,
1266            status: NodeStatus::Pending,
1267            output: None,
1268            compacted_output: None,
1269            error: None,
1270        }
1271    }
1272
1273    fn node(id: &str, deps: &[&str]) -> TaskNode {
1274        TaskNode {
1275            id: NodeId(id.into()),
1276            goal: id.into(),
1277            depends_on: deps.iter().map(|d| NodeId((*d).to_string())).collect(),
1278            tool_allowlist: vec![],
1279            kind: TaskNodeKind::Leaf,
1280            role: None,
1281            status: NodeStatus::Pending,
1282            output: None,
1283            compacted_output: None,
1284            error: None,
1285        }
1286    }
1287
1288    fn ids(v: Vec<&NodeId>) -> Vec<&str> {
1289        v.into_iter().map(|id| id.0.as_str()).collect()
1290    }
1291
1292    // ------------------------------------------------------------------
1293    // validate_acyclic — valid graphs
1294    // ------------------------------------------------------------------
1295
1296    #[test]
1297    fn empty_graph_is_valid() {
1298        let g = TaskGraph {
1299            goal: "g".into(),
1300            hitl_mode: HitlMode::None,
1301            nodes: HashMap::new(),
1302        };
1303        assert!(g.validate_acyclic().is_ok());
1304    }
1305
1306    #[test]
1307    fn single_node_is_valid() {
1308        let g = TaskGraph::new("g".into(), HitlMode::None, vec![leaf("a")]).unwrap();
1309        assert!(g.validate_acyclic().is_ok());
1310    }
1311
1312    #[test]
1313    fn linear_chain_is_valid() {
1314        // a → b → c
1315        let g = TaskGraph::new(
1316            "g".into(),
1317            HitlMode::None,
1318            vec![leaf("a"), node("b", &["a"]), node("c", &["b"])],
1319        )
1320        .unwrap();
1321        assert!(g.validate_acyclic().is_ok());
1322    }
1323
1324    #[test]
1325    fn diamond_shape_is_valid() {
1326        // root → (left, right) → merge
1327        let g = TaskGraph::new(
1328            "g".into(),
1329            HitlMode::None,
1330            vec![
1331                leaf("root"),
1332                node("left", &["root"]),
1333                node("right", &["root"]),
1334                node("merge", &["left", "right"]),
1335            ],
1336        )
1337        .unwrap();
1338        assert!(g.validate_acyclic().is_ok());
1339    }
1340
1341    // ------------------------------------------------------------------
1342    // validate_acyclic — invalid graphs
1343    // ------------------------------------------------------------------
1344
1345    #[test]
1346    fn simple_cycle_is_rejected() {
1347        // a → b → a
1348        let nodes = vec![node("a", &["b"]), node("b", &["a"])];
1349        let g = TaskGraph {
1350            goal: "g".into(),
1351            hitl_mode: HitlMode::None,
1352            nodes: nodes.into_iter().map(|n| (n.id.clone(), n)).collect(),
1353        };
1354        assert!(matches!(
1355            g.validate_acyclic(),
1356            Err(TaskGraphError::Cycle(_))
1357        ));
1358    }
1359
1360    #[test]
1361    fn self_loop_is_rejected() {
1362        let nodes = vec![node("a", &["a"])];
1363        let g = TaskGraph {
1364            goal: "g".into(),
1365            hitl_mode: HitlMode::None,
1366            nodes: nodes.into_iter().map(|n| (n.id.clone(), n)).collect(),
1367        };
1368        assert!(matches!(
1369            g.validate_acyclic(),
1370            Err(TaskGraphError::Cycle(_))
1371        ));
1372    }
1373
1374    #[test]
1375    fn unknown_dependency_is_rejected() {
1376        let nodes = vec![node("a", &["nonexistent"])];
1377        let g = TaskGraph {
1378            goal: "g".into(),
1379            hitl_mode: HitlMode::None,
1380            nodes: nodes.into_iter().map(|n| (n.id.clone(), n)).collect(),
1381        };
1382        assert!(matches!(
1383            g.validate_acyclic(),
1384            Err(TaskGraphError::UnknownDependency { .. })
1385        ));
1386    }
1387
1388    #[test]
1389    fn too_many_nodes_is_rejected() {
1390        let nodes: Vec<TaskNode> = (0..=MAX_NODES).map(|i| leaf(&i.to_string())).collect();
1391        assert!(matches!(
1392            TaskGraph::new("g".into(), HitlMode::None, nodes),
1393            Err(TaskGraphError::TooManyNodes { .. })
1394        ));
1395    }
1396
1397    #[test]
1398    fn duplicate_node_id_is_rejected() {
1399        let nodes = vec![leaf("a"), leaf("a")];
1400        assert!(matches!(
1401            TaskGraph::new("g".into(), HitlMode::None, nodes),
1402            Err(TaskGraphError::DuplicateNodeId(_))
1403        ));
1404    }
1405
1406    #[test]
1407    fn depth_exceeded_is_rejected() {
1408        // Chain of MAX_DEPTH + 2 nodes creates a path of depth MAX_DEPTH + 1.
1409        let mut nodes = vec![leaf("0")];
1410        for i in 1..=(MAX_DEPTH + 1) {
1411            nodes.push(node(&i.to_string(), &[&(i - 1).to_string()]));
1412        }
1413        assert!(matches!(
1414            TaskGraph::new("g".into(), HitlMode::None, nodes),
1415            Err(TaskGraphError::DepthExceeded { .. })
1416        ));
1417    }
1418
1419    // ------------------------------------------------------------------
1420    // roots
1421    // ------------------------------------------------------------------
1422
1423    #[test]
1424    fn roots_returns_nodes_with_no_deps() {
1425        // Diamond: root → (left, right) → merge.  Only root has no deps.
1426        let g = TaskGraph::new(
1427            "g".into(),
1428            HitlMode::None,
1429            vec![
1430                leaf("root"),
1431                node("left", &["root"]),
1432                node("right", &["root"]),
1433                node("merge", &["left", "right"]),
1434            ],
1435        )
1436        .unwrap();
1437        assert_eq!(ids(g.roots()), vec!["root"]);
1438    }
1439
1440    #[test]
1441    fn roots_returns_all_when_no_deps() {
1442        let g = TaskGraph::new("g".into(), HitlMode::None, vec![leaf("a"), leaf("b")]).unwrap();
1443        // Two independent nodes — both are roots (sorted).
1444        assert_eq!(ids(g.roots()), vec!["a", "b"]);
1445    }
1446
1447    // ------------------------------------------------------------------
1448    // ready_nodes
1449    // ------------------------------------------------------------------
1450
1451    #[test]
1452    fn ready_nodes_returns_roots_when_nothing_completed() {
1453        let g = TaskGraph::new(
1454            "g".into(),
1455            HitlMode::None,
1456            vec![leaf("a"), node("b", &["a"])],
1457        )
1458        .unwrap();
1459        let ready = ids(g.ready_nodes(&HashSet::new()));
1460        assert_eq!(ready, vec!["a"]);
1461    }
1462
1463    #[test]
1464    fn ready_nodes_unlocks_children_after_parent_completes() {
1465        // a → b → c
1466        let g = TaskGraph::new(
1467            "g".into(),
1468            HitlMode::None,
1469            vec![leaf("a"), node("b", &["a"]), node("c", &["b"])],
1470        )
1471        .unwrap();
1472
1473        let done: HashSet<NodeId> = [NodeId("a".into())].into();
1474        let ready = ids(g.ready_nodes(&done));
1475        assert_eq!(ready, vec!["b"]);
1476    }
1477
1478    #[test]
1479    fn ready_nodes_requires_all_deps_to_complete() {
1480        // a, b → merge
1481        let g = TaskGraph::new(
1482            "g".into(),
1483            HitlMode::None,
1484            vec![leaf("a"), leaf("b"), node("merge", &["a", "b"])],
1485        )
1486        .unwrap();
1487
1488        // Only "a" done — "merge" still blocked, "b" is newly ready.
1489        let done: HashSet<NodeId> = [NodeId("a".into())].into();
1490        let ready = ids(g.ready_nodes(&done));
1491        assert_eq!(ready, vec!["b"]);
1492
1493        // Both done — "merge" is ready.
1494        let done: HashSet<NodeId> = [NodeId("a".into()), NodeId("b".into())].into();
1495        let ready = ids(g.ready_nodes(&done));
1496        assert_eq!(ready, vec!["merge"]);
1497    }
1498
1499    #[test]
1500    fn ready_nodes_returns_empty_when_all_completed() {
1501        let g = TaskGraph::new(
1502            "g".into(),
1503            HitlMode::None,
1504            vec![leaf("a"), node("b", &["a"])],
1505        )
1506        .unwrap();
1507        let done: HashSet<NodeId> = [NodeId("a".into()), NodeId("b".into())].into();
1508        assert!(g.ready_nodes(&done).is_empty());
1509    }
1510
1511    // ------------------------------------------------------------------
1512    // validate_tool_allowlist
1513    // ------------------------------------------------------------------
1514
1515    #[test]
1516    fn validate_tool_allowlist_passes_when_all_tools_known() {
1517        let catalog = vec![ToolDefinition {
1518            name: "search".into(),
1519            description: None,
1520            input_schema: None,
1521            title: None,
1522        }];
1523        let mut n = leaf("a");
1524        n.tool_allowlist = vec!["search".into()];
1525        let g = TaskGraph::new("g".into(), HitlMode::None, vec![n]).unwrap();
1526        assert!(g.validate_tool_allowlist(&catalog).is_ok());
1527    }
1528
1529    #[test]
1530    fn validate_tool_allowlist_rejects_unknown_tool() {
1531        let catalog = vec![ToolDefinition {
1532            name: "search".into(),
1533            description: None,
1534            input_schema: None,
1535            title: None,
1536        }];
1537        let mut n = leaf("a");
1538        n.tool_allowlist = vec!["unknown_tool".into()];
1539        let g = TaskGraph::new("g".into(), HitlMode::None, vec![n]).unwrap();
1540        assert!(matches!(
1541            g.validate_tool_allowlist(&catalog),
1542            Err(TaskGraphError::UnknownTool { .. })
1543        ));
1544    }
1545
1546    // ------------------------------------------------------------------
1547    // total_node_count
1548    // ------------------------------------------------------------------
1549
1550    #[test]
1551    fn total_node_count_flat_graph() {
1552        let g = TaskGraph::new(
1553            "g".into(),
1554            HitlMode::None,
1555            vec![leaf("a"), leaf("b"), node("c", &["a", "b"])],
1556        )
1557        .unwrap();
1558        assert_eq!(g.total_node_count(), 3);
1559    }
1560
1561    #[test]
1562    fn total_node_count_with_team_subgraph() {
1563        // Build subgraph: researcher → writer (2 nodes).
1564        let subgraph = TaskGraph::new(
1565            "department".into(),
1566            HitlMode::None,
1567            vec![leaf("researcher"), node("writer", &["researcher"])],
1568        )
1569        .unwrap();
1570
1571        // Top-level: team_node + synthesizer (2 nodes at top level).
1572        // total = 2 (top) + 2 (subgraph) = 4.
1573        let team_node = TaskNode {
1574            id: NodeId("dept".into()),
1575            goal: "Run department".into(),
1576            depends_on: vec![],
1577            tool_allowlist: vec![],
1578            kind: TaskNodeKind::Team {
1579                subgraph: Box::new(subgraph),
1580            },
1581            role: None,
1582            status: NodeStatus::Pending,
1583            output: None,
1584            compacted_output: None,
1585            error: None,
1586        };
1587        let synth = leaf("synthesize");
1588        // Build with raw map to bypass node-count limits at this level.
1589        let mut nodes = HashMap::new();
1590        nodes.insert(team_node.id.clone(), team_node);
1591        nodes.insert(synth.id.clone(), synth);
1592        let g = TaskGraph {
1593            goal: "top".into(),
1594            hitl_mode: HitlMode::None,
1595            nodes,
1596        };
1597        assert_eq!(g.total_node_count(), 4);
1598    }
1599
1600    // ------------------------------------------------------------------
1601    // Team subgraph validation
1602    // ------------------------------------------------------------------
1603
1604    #[test]
1605    fn team_subgraph_cycle_is_detected() {
1606        // Build a subgraph with a cycle: x → y → x.
1607        let mut sub_nodes = HashMap::new();
1608        for (id, dep) in [("x", "y"), ("y", "x")] {
1609            sub_nodes.insert(
1610                NodeId(id.into()),
1611                TaskNode {
1612                    id: NodeId(id.into()),
1613                    goal: id.into(),
1614                    depends_on: vec![NodeId(dep.into())],
1615                    tool_allowlist: vec![],
1616                    kind: TaskNodeKind::Leaf,
1617                    role: None,
1618                    status: NodeStatus::Pending,
1619                    output: None,
1620                    compacted_output: None,
1621                    error: None,
1622                },
1623            );
1624        }
1625        let cyclic_subgraph = TaskGraph {
1626            goal: "sub".into(),
1627            hitl_mode: HitlMode::None,
1628            nodes: sub_nodes,
1629        };
1630
1631        // Wrap in a team node at the top level.
1632        let team_node = TaskNode {
1633            id: NodeId("team".into()),
1634            goal: "team".into(),
1635            depends_on: vec![],
1636            tool_allowlist: vec![],
1637            kind: TaskNodeKind::Team {
1638                subgraph: Box::new(cyclic_subgraph),
1639            },
1640            role: None,
1641            status: NodeStatus::Pending,
1642            output: None,
1643            compacted_output: None,
1644            error: None,
1645        };
1646        let mut top_nodes = HashMap::new();
1647        top_nodes.insert(team_node.id.clone(), team_node);
1648        let g = TaskGraph {
1649            goal: "top".into(),
1650            hitl_mode: HitlMode::None,
1651            nodes: top_nodes,
1652        };
1653        assert!(matches!(
1654            g.validate_acyclic(),
1655            Err(TaskGraphError::Cycle(_))
1656        ));
1657    }
1658
1659    #[test]
1660    fn team_subgraph_per_subgraph_node_cap_is_independent() {
1661        // A top-level graph with 8 nodes is valid even if a Team node's
1662        // subgraph also has 8 nodes (per-subgraph caps, not global).
1663        let sub_nodes: Vec<TaskNode> = (0..MAX_NODES).map(|i| leaf(&format!("sub_{i}"))).collect();
1664        let subgraph = TaskGraph::new("sub".into(), HitlMode::None, sub_nodes).unwrap();
1665        // Top-level has just 1 Team node — well under MAX_NODES.
1666        let team_node = TaskNode {
1667            id: NodeId("team".into()),
1668            goal: "run dept".into(),
1669            depends_on: vec![],
1670            tool_allowlist: vec![],
1671            kind: TaskNodeKind::Team {
1672                subgraph: Box::new(subgraph),
1673            },
1674            role: None,
1675            status: NodeStatus::Pending,
1676            output: None,
1677            compacted_output: None,
1678            error: None,
1679        };
1680        let result = TaskGraph::new("top".into(), HitlMode::None, vec![team_node]);
1681        assert!(result.is_ok(), "per-subgraph cap must not be global");
1682    }
1683
1684    // ------------------------------------------------------------------
1685    // Phase F regression fixture
1686    // ------------------------------------------------------------------
1687
1688    #[test]
1689    fn phase_f_fixture_deserializes_and_validates() {
1690        let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1691            .join("../../tests/fixtures/orchestrator/phase_f_baseline.json");
1692        let json = std::fs::read_to_string(&fixture_path)
1693            .unwrap_or_else(|e| panic!("could not read fixture {}: {e}", fixture_path.display()));
1694        let graph: TaskGraph =
1695            serde_json::from_str(&json).expect("fixture must deserialize without error");
1696        graph
1697            .validate_acyclic()
1698            .expect("fixture must pass validate_acyclic");
1699        // Flat Phase F plan — all nodes are leaves.
1700        assert!(
1701            graph
1702                .nodes
1703                .values()
1704                .all(|n| matches!(n.kind, TaskNodeKind::Leaf)),
1705            "Phase F fixture must contain only Leaf nodes"
1706        );
1707        // Aggregate count equals top-level count (no subgraphs).
1708        assert_eq!(graph.total_node_count(), graph.nodes.len());
1709    }
1710
1711    // ------------------------------------------------------------------
1712    // TaskNodeKind serde round-trip
1713    // ------------------------------------------------------------------
1714
1715    #[test]
1716    fn task_node_kind_leaf_is_default_when_absent() {
1717        // Old-format JSON node without a `kind` field must deserialise as Leaf.
1718        let json = r#"{
1719            "id": "x",
1720            "goal": "do something",
1721            "depends_on": [],
1722            "tool_allowlist": [],
1723            "status": "pending"
1724        }"#;
1725        let node: TaskNode = serde_json::from_str(json).unwrap();
1726        assert!(matches!(node.kind, TaskNodeKind::Leaf));
1727        assert!(node.role.is_none());
1728    }
1729}