jobq graph: state filter on /api/jobq/graph + multi-select checkboxes
GET /api/jobq/graph gains a states query param (comma-separated hive_jobq::State names): narrows the served root groups to the named states, keeping a group whole (filtering by a root's own state, which is already its subtree's rolled-up answer). Absent, empty, or fully unrecognised is the identity filter, matching prior behaviour. hive-jobq-graph.js gains a row of per-state checkboxes above the tree, re-fetching the endpoint with the selection on toggle. Default selection hides Done and Skipped. Server-side filtering (not client-side hiding) so hive-jobq-graph-update's node list, and everything downstream of it in builds.js (count pill, live-log panel), only ever sees what's actually shown.
This commit is contained in:
parent
ec476dfaae
commit
1e13b88c8c
6 changed files with 236 additions and 27 deletions
|
|
@ -255,6 +255,24 @@ Settled entries render their **full step tree**, not just a bare
|
||||||
summary — unlike the old `DagView` projection, this wire does not
|
summary — unlike the old `DagView` projection, this wire does not
|
||||||
filter `Done` nodes out.
|
filter `Done` nodes out.
|
||||||
|
|
||||||
|
**State filter (hyperhive#2606).** A row of per-state checkboxes above
|
||||||
|
the tree — one per lifecycle state, matching the row glyphs — lets the
|
||||||
|
operator narrow which root groups render; unchecking a state re-fetches
|
||||||
|
`GET /api/jobq/graph?states=<checked, comma-joined>` rather than
|
||||||
|
hiding rows client-side, so `hive-jobq-graph-update`'s node list (and
|
||||||
|
everything downstream of it — the count pill, the live-log panel) only
|
||||||
|
ever sees what's actually shown. Filtering is by a **root's own**
|
||||||
|
state, which is already its subtree's rolled-up answer, so a group is
|
||||||
|
kept or dropped whole, never split mid-tree. Default selection is
|
||||||
|
every state **except** `Done`/`Skipped` — a fresh queue view leads
|
||||||
|
with what's still moving or needs attention, not the settled tail; the
|
||||||
|
`states` param is omitted entirely (identical request to before this
|
||||||
|
filter existed) when every state is checked. Server-side: the query
|
||||||
|
narrows [`Queue::graph_snapshot`]'s already-bounded (`MAX_HISTORY_DAGS`)
|
||||||
|
root set — the history cap and the state filter are independent
|
||||||
|
concerns, so a narrow filter never reaches further back in time to
|
||||||
|
compensate.
|
||||||
|
|
||||||
Below the queue, a **live build-log panel** (`#rebuild-live-log`,
|
Below the queue, a **live build-log panel** (`#rebuild-live-log`,
|
||||||
`renderRebuildLiveLog`) shows the currently-running rebuild's output
|
`renderRebuildLiveLog`) shows the currently-running rebuild's output
|
||||||
inline — collapsible, with a live/ok/fail badge and a `↓ raw`
|
inline — collapsible, with a live/ok/fail badge and a `↓ raw`
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,26 @@
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jg-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.15em 0.9em;
|
||||||
|
margin: 0 0 0.5em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jg-filter-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3em;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.jg-filter-label:has(input:checked) {
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
.jg-body {
|
.jg-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -14,20 +14,20 @@
|
||||||
// `buildTree`'s `_waitsOn` resolution. `cancellable` attribute adds a
|
// `buildTree`'s `_waitsOn` resolution. `cancellable` attribute adds a
|
||||||
// per-node cancel button dispatching `hive-jobq-graph-cancel` instead of
|
// per-node cancel button dispatching `hive-jobq-graph-cancel` instead of
|
||||||
// POSTing anything itself — see `CANCELLABLE_STATES` + the click-delegate
|
// POSTing anything itself — see `CANCELLABLE_STATES` + the click-delegate
|
||||||
// in `connectedCallback` below for both.
|
// in `connectedCallback` below for both. A per-state checkbox row above
|
||||||
|
// the tree re-fetches `endpoint` with `?states=<checked>` on toggle — see
|
||||||
|
// `_buildFilterBar`/`_fetchUrl` below and docs/web-ui/dashboard.md's
|
||||||
|
// R3BU1LD QU3U3 section for the full filter rationale.
|
||||||
//
|
//
|
||||||
// Usage: <hive-jobq-graph endpoint="/api/jobq/graph"></hive-jobq-graph> —
|
// Usage: <hive-jobq-graph endpoint="/api/jobq/graph"></hive-jobq-graph> —
|
||||||
// self-fetches on connect. `.refresh()` re-fetches + re-renders;
|
// self-fetches on connect. `.refresh()` re-fetches + re-renders;
|
||||||
// `.render(nodes)` renders host-pushed data directly, no fetch. Every
|
// `.render(nodes)` renders host-pushed data directly, no fetch. Every
|
||||||
// render dispatches a bubbling/composed `hive-jobq-graph-update` event
|
// render dispatches a bubbling/composed `hive-jobq-graph-update` event
|
||||||
// (`detail: { nodes }`) so a host needing the raw list for something the
|
// (`detail: { nodes }`) so a host needing the raw list (a count badge, a
|
||||||
// tree doesn't show (a count badge, a live-log panel) listens instead of
|
// live-log panel) listens instead of running its own parallel fetch.
|
||||||
// running its own parallel fetch.
|
|
||||||
//
|
//
|
||||||
// Shadow DOM + own styles, per instruction — unlike light-DOM
|
// Shadow DOM + own styles: theme custom properties (--fg, --red, ...)
|
||||||
// <hive-tab-strip>, this renders a whole subtree nothing else needs to
|
// pierce the shadow boundary by inheritance; only plain class rules local.
|
||||||
// select into. Theme custom properties (--fg, --red, ...) still pierce
|
|
||||||
// the shadow boundary by inheritance; only plain class rules are local.
|
|
||||||
|
|
||||||
import { el } from '../dom.js';
|
import { el } from '../dom.js';
|
||||||
import { attachShadowCss } from '../shadow-css.js';
|
import { attachShadowCss } from '../shadow-css.js';
|
||||||
|
|
@ -43,6 +43,14 @@ const STATE_GLYPH = {
|
||||||
Skipped: '·',
|
Skipped: '·',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Declaration order doubles as render order for the filter checkboxes —
|
||||||
|
// matches `hive_jobq_wire::ALL_STATES` on the wire, so the row reads in the
|
||||||
|
// same lifecycle order the rollup endpoint counts in.
|
||||||
|
const ALL_STATES = Object.keys(STATE_GLYPH);
|
||||||
|
|
||||||
|
// Product call: "default selection filters out skipped and done."
|
||||||
|
const DEFAULT_HIDDEN_STATES = new Set(['Done', 'Skipped']);
|
||||||
|
|
||||||
// Non-terminal states a cancel button makes sense on. Finishing is
|
// Non-terminal states a cancel button makes sense on. Finishing is
|
||||||
// included — "own logic done, children still running" is still a subtree
|
// included — "own logic done, children still running" is still a subtree
|
||||||
// worth stopping early.
|
// worth stopping early.
|
||||||
|
|
@ -133,9 +141,14 @@ class HiveJobqGraph extends HTMLElement {
|
||||||
// <hive-agent-menu> hit when a row cache moves an already-built
|
// <hive-agent-menu> hit when a row cache moves an already-built
|
||||||
// element without a real detach.
|
// element without a real detach.
|
||||||
if (this._root) return;
|
if (this._root) return;
|
||||||
|
// Set before the first `refresh()` call below, so the very first fetch
|
||||||
|
// already carries the default filter rather than flashing every state
|
||||||
|
// and re-fetching a moment later.
|
||||||
|
this._selectedStates = new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s)));
|
||||||
this._root = attachShadowCss(this, graphCss);
|
this._root = attachShadowCss(this, graphCss);
|
||||||
|
this._filterBar = this._buildFilterBar();
|
||||||
this._body = el('div', { class: 'jg-body' });
|
this._body = el('div', { class: 'jg-body' });
|
||||||
this._root.append(this._body);
|
this._root.append(this._filterBar, this._body);
|
||||||
// One delegated listener rather than a per-button one — cancel buttons
|
// One delegated listener rather than a per-button one — cancel buttons
|
||||||
// come and go on every re-render, a delegated listener on the stable
|
// come and go on every re-render, a delegated listener on the stable
|
||||||
// container doesn't need rebinding.
|
// container doesn't need rebinding.
|
||||||
|
|
@ -151,15 +164,51 @@ class HiveJobqGraph extends HTMLElement {
|
||||||
this.refresh();
|
this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-fetch `endpoint` and re-render. Public so a host page can call it
|
// One checkbox per `ALL_STATES` entry, pre-ticked per `_selectedStates`.
|
||||||
// on its own refresh cadence (SSE tick, poll, whatever fits the page) —
|
// Built once at connect — toggling a box mutates `_selectedStates` and
|
||||||
// this element intentionally owns no transport of its own.
|
// re-fetches rather than rebuilding the row, so focus/scroll position in
|
||||||
async refresh() {
|
// the row itself is never disturbed by a data refresh.
|
||||||
|
_buildFilterBar() {
|
||||||
|
const bar = el('div', { class: 'jg-filter' });
|
||||||
|
for (const state of ALL_STATES) {
|
||||||
|
const id = 'jg-filter-' + state.toLowerCase();
|
||||||
|
const cb = el('input', { type: 'checkbox', id });
|
||||||
|
cb.checked = this._selectedStates.has(state);
|
||||||
|
cb.addEventListener('change', () => {
|
||||||
|
if (cb.checked) this._selectedStates.add(state);
|
||||||
|
else this._selectedStates.delete(state);
|
||||||
|
this.refresh();
|
||||||
|
});
|
||||||
|
const label = el('label', { for: id, class: 'jg-filter-label jg-state-' + state.toLowerCase() },
|
||||||
|
cb, ' ', STATE_GLYPH[state] + ' ' + state);
|
||||||
|
bar.append(label);
|
||||||
|
}
|
||||||
|
return bar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `endpoint` plus the current filter selection as a `states=` query
|
||||||
|
// param — omitted entirely when every state is checked, so the
|
||||||
|
// unfiltered default case sends the exact same request as before this
|
||||||
|
// filter existed.
|
||||||
|
_fetchUrl() {
|
||||||
const endpoint = this.getAttribute('endpoint');
|
const endpoint = this.getAttribute('endpoint');
|
||||||
if (!endpoint || !this._body) return;
|
if (!endpoint) return null;
|
||||||
|
if (!this._selectedStates || this._selectedStates.size >= ALL_STATES.length) return endpoint;
|
||||||
|
const url = new URL(endpoint, window.location.origin);
|
||||||
|
url.searchParams.set('states', Array.from(this._selectedStates).join(','));
|
||||||
|
return url.pathname + url.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch `endpoint` (filtered by the current checkbox selection) and
|
||||||
|
// re-render. Public so a host page can call it on its own refresh
|
||||||
|
// cadence (SSE tick, poll, whatever fits the page) — this element
|
||||||
|
// intentionally owns no transport of its own.
|
||||||
|
async refresh() {
|
||||||
|
const url = this._fetchUrl();
|
||||||
|
if (!url || !this._body) return;
|
||||||
let nodes;
|
let nodes;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(endpoint);
|
const r = await fetch(url);
|
||||||
if (!r.ok) throw new Error('http ' + r.status);
|
if (!r.ok) throw new Error('http ' + r.status);
|
||||||
nodes = await r.json();
|
nodes = await r.json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -651,24 +651,57 @@ fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `/api/jobq/graph` query string. Today's only field is `states`: a
|
||||||
|
/// comma-separated allow-list of `hive_jobq::State` names (`"Pending"`,
|
||||||
|
/// `"Running"`, ...). Empty / absent ⇒ no filter (current behaviour, every
|
||||||
|
/// visible root). Set ⇒ only **root** groups whose own state is named are
|
||||||
|
/// served — a root's state is already its subtree's rolled-up answer (see
|
||||||
|
/// `hive_jobq_wire`'s doc), so filtering the root filters the whole group.
|
||||||
|
/// Unknown tokens are silently ignored (an unrecognised name matches
|
||||||
|
/// nothing rather than erroring the whole request), mirroring
|
||||||
|
/// `DashboardStreamQuery::kinds` above.
|
||||||
|
#[derive(Deserialize, Default, IntoParams)]
|
||||||
|
pub(super) struct JobqGraphQuery {
|
||||||
|
states: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses [`JobqGraphQuery::states`] into the list
|
||||||
|
/// [`crate::job_queue::JobQueue::graph_snapshot`] wants. `None` when absent
|
||||||
|
/// or when every token failed to parse — both mean "no filter" rather than
|
||||||
|
/// "match nothing", so an empty/garbled query reads as the unfiltered call
|
||||||
|
/// it replaces rather than an empty result set.
|
||||||
|
fn parse_states(raw: Option<&str>) -> Option<Vec<hive_jobq::State>> {
|
||||||
|
let states: Vec<hive_jobq::State> = raw?
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.filter_map(|s| serde_json::from_value(serde_json::Value::String(s.to_owned())).ok())
|
||||||
|
.collect();
|
||||||
|
(!states.is_empty()).then_some(states)
|
||||||
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/jobq/graph",
|
path = "/api/jobq/graph",
|
||||||
|
params(JobqGraphQuery),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "every node of every retained job group, \
|
(status = 200, description = "every node of every retained job group, \
|
||||||
as generic `hive_jobq` graph nodes: identity, the parent tree, \
|
as generic `hive_jobq` graph nodes: identity, the parent tree, \
|
||||||
dependency edges with their accepted-outcome sets, lifecycle, and \
|
dependency edges with their accepted-outcome sets, lifecycle, and \
|
||||||
one opaque per-node payload. Group roots ride as ordinary nodes \
|
one opaque per-node payload. Group roots ride as ordinary nodes \
|
||||||
(`parent: null`) and `Done` nodes are not filtered — a consumer \
|
(`parent: null`) and `Done` nodes are not filtered by default — a \
|
||||||
renders the graph without knowing what any node means.",
|
consumer renders the graph without knowing what any node means. \
|
||||||
|
`?states=` narrows to root groups in the named states.",
|
||||||
body = Vec<hive_jobq_wire::GraphNode>),
|
body = Vec<hive_jobq_wire::GraphNode>),
|
||||||
),
|
),
|
||||||
tag = "state_snapshot"
|
tag = "state_snapshot"
|
||||||
)]
|
)]
|
||||||
pub(super) async fn jobq_graph(
|
pub(super) async fn jobq_graph(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
axum::extract::Query(q): axum::extract::Query<JobqGraphQuery>,
|
||||||
) -> axum::Json<Vec<hive_jobq_wire::GraphNode>> {
|
) -> axum::Json<Vec<hive_jobq_wire::GraphNode>> {
|
||||||
axum::Json(state.coord.job_queue.graph_snapshot())
|
let states = parse_states(q.states.as_deref());
|
||||||
|
axum::Json(state.coord.job_queue.graph_snapshot(states.as_deref()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
|
||||||
|
|
@ -326,19 +326,32 @@ impl JobQueue {
|
||||||
|
|
||||||
/// Every node of every visible group, as generic graph nodes.
|
/// Every node of every visible group, as generic graph nodes.
|
||||||
///
|
///
|
||||||
/// **Nothing is hidden.** Group roots ride as ordinary nodes (so a
|
/// **Nothing is hidden**, other than an explicit `states` ask. Group
|
||||||
/// consumer needs no special case for "the container" and reads the
|
/// roots ride as ordinary nodes (so a consumer needs no special case for
|
||||||
/// root's own `state` as the group's answer), and `Done` nodes stay (so a
|
/// "the container" and reads the root's own `state` as the group's
|
||||||
/// finished step is visible rather than vanishing from the payload, which
|
/// answer), and `Done` nodes stay by default (so a finished step is
|
||||||
/// is what makes a fast rebuild render as a single node).
|
/// visible rather than vanishing from the payload, which is what makes a
|
||||||
|
/// fast rebuild render as a single node).
|
||||||
|
///
|
||||||
|
/// `states`, when given, keeps only the **root** groups whose own state
|
||||||
|
/// is named — a root's state is already its subtree's rolled-up answer
|
||||||
|
/// (see `hive_jobq_wire`'s doc), so filtering the root filters the whole
|
||||||
|
/// group without needing to also filter descendants. `None` (or the
|
||||||
|
/// full state set) is the unfiltered call, matching prior behaviour.
|
||||||
|
/// A slice rather than a set: `State` (`hive_jobq`'s) derives `Eq` but
|
||||||
|
/// not `Hash`, and the whole vocabulary is 7 variants — a linear
|
||||||
|
/// membership check per root costs nothing at that size, so there is no
|
||||||
|
/// reason to widen `hive_jobq::State`'s derive list just for this.
|
||||||
///
|
///
|
||||||
/// The projection itself is [`hive_jobq_wire`]'s; all this layer supplies
|
/// The projection itself is [`hive_jobq_wire`]'s; all this layer supplies
|
||||||
/// is *which* groups to show — see [`visible_roots`] for why the graph
|
/// is *which* groups to show — see [`visible_roots`] for why the graph
|
||||||
/// can't decide that for itself.
|
/// can't decide that for itself.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn graph_snapshot(&self) -> Vec<GraphNode> {
|
pub fn graph_snapshot(&self, states: Option<&[State]>) -> Vec<GraphNode> {
|
||||||
let inner = self.lock();
|
let inner = self.lock();
|
||||||
inner.graph().wire_snapshot(visible_roots(&inner))
|
let roots = visible_roots(&inner);
|
||||||
|
let roots = filter_roots_by_state(&inner, roots, states);
|
||||||
|
inner.graph().wire_snapshot(roots)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-state counts over the **same** groups [`Queue::graph_snapshot`]
|
/// Per-state counts over the **same** groups [`Queue::graph_snapshot`]
|
||||||
|
|
@ -393,6 +406,33 @@ fn find_node(sched: &Sched, id: u64) -> Option<NodeId> {
|
||||||
.find_map(|n| (n.id.get() == id).then_some(n.id))
|
.find_map(|n| (n.id.get() == id).then_some(n.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`Queue::graph_snapshot`]'s `states` ask, applied to an already-bounded
|
||||||
|
/// root set: keeps only the roots whose own `state` is named.
|
||||||
|
///
|
||||||
|
/// Applied *after* [`visible_roots`] rather than folded into it — the
|
||||||
|
/// history cap bounds how much settled work is retained at all, which is a
|
||||||
|
/// different question from which of the retained (and live) groups the
|
||||||
|
/// caller wants shown right now. `None` (or an unrecognised/absent query)
|
||||||
|
/// is the identity filter.
|
||||||
|
fn filter_roots_by_state(
|
||||||
|
sched: &Sched,
|
||||||
|
roots: Vec<NodeId>,
|
||||||
|
states: Option<&[State]>,
|
||||||
|
) -> Vec<NodeId> {
|
||||||
|
let Some(states) = states else {
|
||||||
|
return roots;
|
||||||
|
};
|
||||||
|
roots
|
||||||
|
.into_iter()
|
||||||
|
.filter(|&id| {
|
||||||
|
sched
|
||||||
|
.graph()
|
||||||
|
.node(id)
|
||||||
|
.is_some_and(|n| states.contains(&n.state))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The visible **group** set for [`Queue::graph_snapshot`]: every live group
|
/// The visible **group** set for [`Queue::graph_snapshot`]: every live group
|
||||||
/// root, plus the newest [`MAX_HISTORY_DAGS`] settled ones.
|
/// root, plus the newest [`MAX_HISTORY_DAGS`] settled ones.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -306,7 +306,7 @@ fn state_of(q: &JobQueue, dag_id: u64) -> State {
|
||||||
// rolled-up outcome), so there is nothing to derive here any more.
|
// rolled-up outcome), so there is nothing to derive here any more.
|
||||||
// A root aged out of the retained history reads `Done`: it settled, or
|
// A root aged out of the retained history reads `Done`: it settled, or
|
||||||
// it would still be live.
|
// it would still be live.
|
||||||
q.graph_snapshot()
|
q.graph_snapshot(None)
|
||||||
.iter()
|
.iter()
|
||||||
.find(|n| n.id == dag_id)
|
.find(|n| n.id == dag_id)
|
||||||
.map_or(State::Done, |n| n.state)
|
.map_or(State::Done, |n| n.state)
|
||||||
|
|
@ -318,7 +318,7 @@ fn state_of(q: &JobQueue, dag_id: u64) -> State {
|
||||||
/// asking "how many DAGs" counts the parentless ones. Counting rows would
|
/// asking "how many DAGs" counts the parentless ones. Counting rows would
|
||||||
/// count steps, which is a different number: one rebuild is ~7 nodes.
|
/// count steps, which is a different number: one rebuild is ~7 nodes.
|
||||||
fn dag_count(q: &JobQueue) -> usize {
|
fn dag_count(q: &JobQueue) -> usize {
|
||||||
q.graph_snapshot()
|
q.graph_snapshot(None)
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|n| n.parent.is_none())
|
.filter(|n| n.parent.is_none())
|
||||||
.count()
|
.count()
|
||||||
|
|
@ -1471,6 +1471,55 @@ fn history_retains_live_dags_and_the_newest_terminals() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `graph_snapshot`'s `states` ask filters by **root** state — a whole group
|
||||||
|
/// is kept or dropped together, never split mid-subtree. `None` is the
|
||||||
|
/// identity filter (every visible root, current default behaviour).
|
||||||
|
#[test]
|
||||||
|
fn graph_snapshot_states_filters_whole_groups_by_root_state() {
|
||||||
|
let q = JobQueue::new(2);
|
||||||
|
let roots_a = insert_named(&q, |builder| restart_online(builder, &["agent-a"], false));
|
||||||
|
let roots_b = insert_named(&q, |builder| restart_online(builder, &["agent-b"], false));
|
||||||
|
let [head_a] = roots_a.as_slice() else {
|
||||||
|
panic!("a one-agent restart names one root, got {roots_a:?}")
|
||||||
|
};
|
||||||
|
let [head_b] = roots_b.as_slice() else {
|
||||||
|
panic!("a one-agent restart names one root, got {roots_b:?}")
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(q.cancel(*head_a), "queued dag cancels");
|
||||||
|
assert_eq!(state_of(&q, *head_a), State::Cancelled);
|
||||||
|
assert_eq!(state_of(&q, *head_b), State::Pending, "untouched sibling");
|
||||||
|
|
||||||
|
let roots_of = |states: Option<&[State]>| -> Vec<u64> {
|
||||||
|
q.graph_snapshot(states)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| n.parent.is_none())
|
||||||
|
.map(|n| n.id)
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
roots_of(Some(&[State::Cancelled])),
|
||||||
|
vec![*head_a],
|
||||||
|
"only the cancelled group's root rides"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
roots_of(Some(&[State::Pending])),
|
||||||
|
vec![*head_b],
|
||||||
|
"only the pending group's root rides"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut both = roots_of(None);
|
||||||
|
both.sort_unstable();
|
||||||
|
let mut expected = vec![*head_a, *head_b];
|
||||||
|
expected.sort_unstable();
|
||||||
|
assert_eq!(
|
||||||
|
both, expected,
|
||||||
|
"no filter shows every visible root, as before"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn error_truncation_cuts_on_a_char_boundary() {
|
fn error_truncation_cuts_on_a_char_boundary() {
|
||||||
// `truncate_error` is a pure `&str -> String`. This used to submit a DAG,
|
// `truncate_error` is a pure `&str -> String`. This used to submit a DAG,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue