feat(jobq): add a job builder that names nodes instead of counting them

`JobBuilder::node(payload)` hands back a `NodeRef` handle obtainable no
other way, and edges are handle -> handle. `insert_into(graph,
root_parent)` consumes the builder, inserts in declaration order, and
returns the id each handle was minted as. There is no intermediate
node-description type: the builder inserts through `Graph::insert`
directly, so nothing has to stay in sync with that signature.

`root_parent` is the group's attachment point — a node that declared no
parent hangs there. That is what makes a job a self-contained sub-DAG: a
template is written without knowing which container node it will live
under, and the same builder serves a runtime-emitted subgraph hanging
off its emitting node.

Generic over the same `N`/`R` as `Graph`, so it belongs to the library
rather than to any one caller's node kind. `.needs(name)` /
`.needs_units(name, count)` declare resource deps at the construction
site, next to the node that needs them.

`Scheduler::insert_job` is the same over a scheduler, via the shared
`insert_with` sink, so a caller building a job never reaches past the
scheduler at the graph underneath.

Declaration order is enforced rather than papered over: a node
referencing one declared later is a named `BuildError::ForwardEdge` /
`ForwardParent`. Sorting for the caller would silently accept a shape
the graph cannot express, and would put ordering logic in a second
place.

Additive — `Graph` is untouched.
This commit is contained in:
atlas 2026-08-02 12:32:13 +02:00 committed by mara
commit f161f8e40f
3 changed files with 547 additions and 0 deletions

View file

@ -31,6 +31,7 @@
use std::collections::HashMap;
use std::hash::Hash;
use crate::builder::{BuildError, JobBuilder, NodeGuid};
use crate::resources::ResourceTable;
use crate::{Dep, Graph, GraphError, NodeId, State, TerminalState};
@ -99,6 +100,28 @@ impl<N, R: Clone + Eq + Hash> Scheduler<N, R> {
self.graph.insert(payload, deps, parent)
}
/// Insert a whole job — every node a [`JobBuilder`] declared — under
/// `root_parent`, returning the id each handle's node was minted as.
///
/// The scheduler-side counterpart of [`JobBuilder::insert_into`]: same
/// resolution, but each node goes through [`Scheduler::append`], so a
/// caller building a job never has to reach past the scheduler at the graph
/// underneath. Call [`Scheduler::settle`] afterwards to start whatever
/// became runnable.
///
/// # Errors
/// Propagates [`BuildError`] — a forward reference in the job's own
/// declarations, or a graph rejection.
pub fn insert_job(
&mut self,
job: JobBuilder<N, R>,
root_parent: Option<NodeId>,
) -> Result<HashMap<NodeGuid, NodeId>, BuildError> {
job.insert_with(root_parent, |payload, deps, parent| {
self.append(payload, deps, parent)
})
}
/// Claim every currently-runnable pending node and start it: node-deps
/// satisfied and all resource-deps acquired atomically (all-or-nothing).
/// Each claimed node is marked `Running`, its acquired units recorded, and