nordisk/dwarves
crates/nornir-funnel-embed/src/lib.rs
//! **nornir-funnel-embed** — the LIVE funnel as an embeddable `Panel` (backend (2)).
//!
//! The reusable `<guest>-embed` adapter shape from
//! `.nornir/wasm-ui-panels-design.md` §3 (holger-embed is the template): a **thin
//! tonic gRPC client** fetches the funnel from a running `nornir-server`'s read-only
//! [`Funnel.Show`](pb) RPC, **reconstructs** a [`funnel_core::state::Funnel`]
//! client-side (mirroring nornir's `viz::remote::funnel_reconstruct`), projects it
//! with the pure [`funnel_core::plan_dag::PlanDag`], and renders it with the shared
//! [`facett_plandag::PlanDagView`] (the metric-bearing Plan-DAG facet).
//!
//! **No `nornir` dependency.** nornir already depends on dwarves, so a `dwarves →
//! nornir` edge would be a cycle. Instead this crate compiles ONLY nornir's funnel
//! `Funnel.Show` proto (a trimmed copy under `proto/`, package `nornir.v1`, so the
//! gRPC method path `/nornir.v1.Funnel/Show` matches the live server) and reconstructs
//! against `funnel-core` + the generated `pb` types directly.
//!
//! ## The seam
//! [`panel`] returns a `Box<dyn Panel>` a host drops into a tab. It connects to the
//! given endpoint (default `http://127.0.0.1:7878`), binds to the **selected
//! warehouse** ([`FunnelPanel::set_workspace`], mirroring nornir's per-tab
//! `set_workspace`), and on any failure shows a clean, DISTINCT state — **no warehouse
//! selected** / **server unreachable** / **auth failed** / **warehouse not served** —
//! never the old catch-all *"offline"* (which lied when the server was up). It never
//! panics. A **Refresh** action re-fetches.
use std::time::Duration;
/// **dwarves's REACTION to a facett UI error code** (facett error-codes, Phase 2) —
/// namespaces a `facet-<component>-<n>` with dwarves' mount
/// (`dwarves/funnel:facet-graphview-1`) and returns the canonical reaction.
/// COMPOSES with `dwarves-<subsystem>-<n>`, never collides.
pub mod facet_react;
/// The funnel DAG rendered as a node-link graph on the shared `facett-graphview`
/// engine — the second render beside the `facett-plandag` card lane.
pub mod graph;
pub use graph::FunnelGraphPane;
/// The funnel DAG rendered as a **3D layered graph** — the third render (a camera-only
/// 3D placement with optional auto-rotate + clickable drill), reusing the world
/// graph's orthographic 3D projection pattern. See [`graph3d`].
pub mod graph3d;
pub use graph3d::{graph3d_build_calls, FunnelGraph3d};
use anyhow::{Context, Result};
use facett_core::{Facet, Panel};
use facett_plandag::{Metrics, PlanDagView, PlanEdge, PlanModel, PlanNode, PlanVersion};
use funnel_core::plan_dag::{DagStatus, PlanDag};
/// The generated thin client + wire types for nornir's `Funnel.Show` (+ the server
/// binding the `testkit` mock mounts). PUBLIC (like `nornir_warehouse_embed::pb`) so
/// the testkit's seed shapes are nameable by a host's robot tests, and so a future
/// dwarves-side `Funnel` proxy can serve these exact types.
pub mod pb {
tonic::include_proto!("nornir.v1");
}
/// The default `nornir-server` endpoint on this box ("dwarf").
pub const DEFAULT_ENDPOINT: &str = "http://127.0.0.1:7878";
// ── wire → funnel-core reconstruction (mirrors nornir viz::remote) ────────────
/// Lenient wire→[`funnel_core::event::NodeStatus`] parse. Accepts the server's
/// snake_case serde token, the `Debug` repr (lower-cased), and the CLI synonyms
/// (`active` ⇒ in-progress, `abandoned` ⇒ failed). Unknown / `pending` ⇒ `Pending`
/// (the safe default — an unrecognised status never panics).
fn node_status_from_wire(s: &str) -> funnel_core::event::NodeStatus {
use funnel_core::event::NodeStatus::*;
match s.trim().to_ascii_lowercase().as_str() {
"ready" => Ready,
"inprogress" | "in_progress" | "active" => InProgress,
"done" => Done,
"blocked" => Blocked,
"failed" | "abandoned" => Failed,
_ => Pending,
}
}
/// Lenient wire→[`funnel_core::event::PlanStatus`] parse (the dump carries the
/// `Debug` repr). Unknown ⇒ `Draft`.
fn plan_status_from_wire(s: &str) -> funnel_core::event::PlanStatus {
use funnel_core::event::PlanStatus::*;
match s.trim().to_ascii_lowercase().as_str() {
"active" => Active,
"done" => Done,
"abandoned" => Abandoned,
_ => Draft,
}
}
/// **Reconstruct a [`funnel_core::state::Funnel`] from a `Funnel.Show` dump.**
///
/// Pure (no network) so it is unit-testable: maps the render-ready `FunnelDump`
/// (ideas → plans → nodes, each node carrying its `deps`) back onto the in-memory
/// `Funnel` the pure DAG projections consume. Re-implemented against `funnel-core`
/// and the generated `pb` types (NOT by importing nornir) — a faithful twin of
/// nornir's `viz::remote::funnel_from_dump`.
///
/// Fidelity notes (additive, never a failure): the dump carries no timestamps, so
/// every card reconstructs with `now` (staleness collapses to a uniform 0 — the
/// topology, statuses, and edges are exact); `refs`/assignments/lanes are absent
/// from the wire and default empty.
pub fn funnel_from_dump(dump: &pb::FunnelDump) -> funnel_core::state::Funnel {
use funnel_core::event::ItemKind;
use funnel_core::ids::{IdeaId, NodeId, PlanId};
use funnel_core::state::{Funnel, Idea, Plan, PlanNode as CorePlanNode};
use std::collections::{BTreeMap, BTreeSet};
let now = chrono::Utc::now();
let mut f = Funnel::default();
for idea in &dump.ideas {
let iid = IdeaId::new(&idea.id);
f.ideas.insert(
iid.clone(),
Idea {
id: iid.clone(),
source: idea.source.clone(),
text: idea.text.clone(),
refs: Vec::new(),
item_kind: ItemKind::parse(&idea.item_kind),
submitted_at: now,
triage: None,
mode: None,
},
);
for plan in &idea.plans {
let pid = PlanId::new(&plan.id);
// Prefer the dump's explicit idea_id, fall back to the enclosing idea.
let plan_idea = if plan.idea_id.is_empty() {
iid.clone()
} else {
IdeaId::new(&plan.idea_id)
};
let mut nodes: BTreeMap<NodeId, CorePlanNode> = BTreeMap::new();
for n in &plan.nodes {
let nid = NodeId::new(&n.id);
let mut params = serde_json::Map::new();
if !n.title.is_empty() {
params.insert("title".into(), serde_json::json!(n.title));
}
nodes.insert(
nid.clone(),
CorePlanNode {
id: nid.clone(),
kind: n.kind.clone(),
params,
targets: Vec::new(),
prompt_excerpt: (!n.title.is_empty()).then(|| n.title.clone()),
created_at: now,
status: node_status_from_wire(&n.status),
last_status_change: now,
},
);
}
// Edges: each node lists its upstream `deps` (dep → node). Skip any edge
// whose endpoints aren't both present (defensive — the pure projections
// tolerate a partial dump, never panic).
let mut edges: BTreeSet<(NodeId, NodeId)> = BTreeSet::new();
let present: BTreeSet<&str> = plan.nodes.iter().map(|n| n.id.as_str()).collect();
for n in &plan.nodes {
let to = NodeId::new(&n.id);
for d in &n.deps {
if present.contains(d.as_str()) && d != &n.id {
edges.insert((NodeId::new(d), to.clone()));
}
}
}
f.plans.insert(
pid.clone(),
Plan {
id: pid,
idea_id: plan_idea,
summary: plan.summary.clone(),
planner: "server".into(),
created_at: now,
status: plan_status_from_wire(&plan.status),
nodes,
edges,
},
);
}
}
f
}
// ── thin gRPC fetch ───────────────────────────────────────────────────────────
/// **The bound on a READ, which `connect_timeout` is not.** A server that ACCEPTS the
/// socket and then never answers clears the connect bound instantly and then waits
/// forever — the shape that left `dwarves-ui-gpu` with no window at all (measured
/// against the live oden forge, 2026-09-06). Pairing the connect bound with a request
/// bound is what makes an unresponsive host degrade to an honest unavailable state.
const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
/// Normalize an endpoint: prefix `http://` if the scheme is missing.
fn norm_endpoint(endpoint: &str) -> String {
if endpoint.starts_with("http") {
endpoint.to_string()
} else {
format!("http://{endpoint}")
}
}
/// The ONE auth interceptor every thin funnel wire call shares: dwarves' bearer as
/// `authorization` plus the selected workspace as `nornir-workspace` metadata.
/// A concrete type (not a closure) so [`dial`] can NAME the intercepted client and
/// the three wire legs ([`fetch_funnel`] / [`submit_idea`] / [`persist_plan`]) share
/// one connect body instead of three twins (L5).
#[derive(Clone)]
struct FunnelAuth {
bearer: Option<tonic::metadata::MetadataValue<tonic::metadata::Ascii>>,
workspace: Option<tonic::metadata::MetadataValue<tonic::metadata::Ascii>>,
}
impl tonic::service::Interceptor for FunnelAuth {
fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
if let Some(b) = &self.bearer {
req.metadata_mut().insert("authorization", b.clone());
}
if let Some(ws) = &self.workspace {
req.metadata_mut().insert("nornir-workspace", ws.clone());
}
Ok(req)
}
}
/// The connected thin client [`dial`] hands back (channel + [`FunnelAuth`]).
type WireClient = pb::funnel_client::FunnelClient<
tonic::service::interceptor::InterceptedService<tonic::transport::Channel, FunnelAuth>,
>;
/// Connect to `nornir-server` at `endpoint` and return the authenticated thin
/// `Funnel` client — the ONE connect+bearer+workspace body all three wire legs
/// share. 3s connect timeout so a dead server never wedges the caller; the
/// "connect to nornir-server" context is what `classify_conn` keys `Unreachable` on.
async fn dial(endpoint: &str, token: &str, workspace: &str) -> Result<WireClient> {
// The ONE shared client bearer-builder (Law #1) — no per-site
// `format!("Bearer …").parse()` twin. `None` ⇒ open (empty token).
let bearer = nornir_service::client::bearer(token).context("parse bearer token")?;
let workspace = (!workspace.is_empty())
.then(|| workspace.parse().ok())
.flatten();
let endpoint = norm_endpoint(endpoint);
let channel = tonic::transport::Channel::from_shared(endpoint.clone())
.with_context(|| format!("invalid server url `{endpoint}`"))?
.connect_timeout(Duration::from_secs(3))
.timeout(READ_TIMEOUT)
.connect()
.await
.with_context(|| format!("connect to nornir-server at {endpoint}"))?;
Ok(
pb::funnel_client::FunnelClient::with_interceptor(
channel,
FunnelAuth { bearer, workspace },
)
.max_decoding_message_size(64 << 20),
)
}
/// Drive a wire future to completion on a private current-thread runtime — safe to
/// call from the synchronous egui loop (shared by the three wire legs, L5).
fn block_on<T>(what: &str, fut: impl std::future::Future<Output = Result<T>>) -> Result<T> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.with_context(|| format!("build tokio runtime for {what}"))?;
rt.block_on(fut)
}
/// Fetch the funnel from `nornir-server` at `endpoint` over the thin `Funnel.Show`
/// gRPC and reconstruct a [`funnel_core::state::Funnel`]. Authenticates with
/// `Bearer <token>` (empty ⇒ no real auth); if `workspace` is non-empty it rides in
/// the `nornir-workspace` metadata header (selects which served workspace's funnel).
/// Readiness is derived (`promote_ready`) so the DAG shows the same Pending→Ready
/// frontier the local path does. Runs on a private current-thread runtime so it is
/// safe to call from the synchronous egui loop; a 3s connect timeout keeps a dead
/// server from wedging the UI thread.
pub fn fetch_funnel(
endpoint: &str,
token: &str,
workspace: &str,
) -> Result<funnel_core::state::Funnel> {
block_on("funnel client", async move {
let mut client = dial(endpoint, token, workspace).await?;
let dump = client
.show(pb::Empty {})
.await
.context("Funnel.Show RPC")?
.into_inner();
let mut f = funnel_from_dump(&dump);
f.promote_ready();
Ok(f)
})
}
/// The intake kind the submit strip carries: an **idea** (a thing to build) or an
/// **error** (a defect to triage). Both are first-class funnel items — the server's
/// `SubmitIdeaRequest.item_kind` routes them (`funnel-intake`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IntakeKind {
Idea,
Error,
}
impl IntakeKind {
/// The wire token the server expects (`""` ⇒ idea; we send it explicitly).
fn wire(self) -> &'static str {
match self {
IntakeKind::Idea => "idea",
IntakeKind::Error => "error",
}
}
}
/// **Submit an idea/error into nornir's funnel over thin gRPC (the WRITE leg).**
///
/// Calls `Funnel.SubmitIdea` on `nornir-server` at `endpoint`, returning the new
/// item's id. Same auth/workspace metadata + private current-thread runtime + 3s
/// connect timeout as [`fetch_funnel`], so it is safe to call from the synchronous
/// egui loop and never wedges the UI thread. Typing an idea here grows the server's
/// plan-DAG, which the pane's auto-poll `Show` then re-renders — the funnel's
/// read→write round-trip, all over gRPC (no `nornir` crate dependency).
pub fn submit_idea(
endpoint: &str,
token: &str,
workspace: &str,
text: &str,
kind: IntakeKind,
) -> Result<String> {
block_on("funnel submit", async move {
let mut client = dial(endpoint, token, workspace).await?;
let id = client
.submit_idea(pb::SubmitIdeaRequest {
text: text.to_string(),
source: "dwarves:funnel-pane".into(),
item_kind: kind.wire().into(),
})
.await
.context("Funnel.SubmitIdea RPC")?
.into_inner()
.id;
Ok(id)
})
}
// ── the decompose PERSIST leg (plan → CreatePlan + AddNode + Link) ────────────
/// One node of a plan to PERSIST — the neutral spec shape a host (dwarves'
/// `funnel_action::FunnelPlan`) lowers its plan into. `local_id` is the plan's OWN
/// node id (e.g. `"n1"`); `needs` references sibling `local_id`s. The server
/// assigns its own sequential node ids, so [`persist_plan`] keeps the local→server
/// map and translates the edges.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanNodeSpec {
/// The plan's own node id (referenced by sibling `needs`).
pub local_id: String,
/// Free verb/kind, e.g. `"code:write"`.
pub kind: String,
/// Human-readable title (what `Funnel.Show` re-renders).
pub title: String,
/// `local_id`s of sibling nodes this node depends on.
pub needs: Vec<String>,
}
/// The server ids a persisted plan landed under — returned so the caller can echo
/// them (and a test can follow them back out of `Funnel.Show`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedPlan {
/// The idea the plan attached to (given, or newly submitted).
pub idea_id: String,
/// The server-assigned plan id (e.g. `"p-003"`).
pub plan_id: String,
/// `local_id → server node id`, in submission order.
pub node_ids: Vec<(String, String)>,
}
/// **Persist a decomposed plan into nornir's funnel (the "funnel-no-plan" fix).**
///
/// Drives the SAME event-sourced write RPCs the nornir CLI uses — `CreatePlan`
/// (⇒ `PlanCreated` + `PlanStatusChanged(Active)` events), `AddNode` (⇒ `NodeAdded`)
/// and `Link` (⇒ `EdgeAdded`) — so the persisted plan reappears in `Funnel.Show`
/// exactly like every other plan; no new server surface, no side store.
///
/// `idea_id: Some(..)` attaches the plan to an EXISTING funnel idea; `None` first
/// submits `idea_text` (source `"dwarves:decompose"`) — nornir's store rejects a
/// plan on an unknown idea, and a plan whose idea is missing would be invisible in
/// `Show`. The plan spec is VALIDATED before the first write (empty plan / dangling
/// `needs` / duplicate `local_id` ⇒ an honest error, no partial persist from a bad
/// spec). Same auth/workspace + timeout posture as [`fetch_funnel`]/[`submit_idea`].
pub fn persist_plan(
endpoint: &str,
token: &str,
workspace: &str,
idea_id: Option<&str>,
idea_text: &str,
summary: &str,
nodes: &[PlanNodeSpec],
) -> Result<PersistedPlan> {
// Validate BEFORE any write — a malformed spec must not half-persist.
if nodes.is_empty() {
anyhow::bail!("refusing to persist an empty plan (no nodes)");
}
let locals: std::collections::BTreeSet<&str> =
nodes.iter().map(|n| n.local_id.as_str()).collect();
if locals.len() != nodes.len() {
anyhow::bail!("plan has duplicate node ids");
}
for n in nodes {
for d in &n.needs {
if !locals.contains(d.as_str()) {
anyhow::bail!("plan node `{}` needs unknown sibling `{d}`", n.local_id);
}
}
}
block_on("funnel persist", async move {
let mut client = dial(endpoint, token, workspace).await?;
let idea_id = match idea_id {
Some(id) if !id.trim().is_empty() => id.to_string(),
_ => {
client
.submit_idea(pb::SubmitIdeaRequest {
text: idea_text.to_string(),
source: "dwarves:decompose".into(),
item_kind: IntakeKind::Idea.wire().into(),
})
.await
.context("Funnel.SubmitIdea RPC (persist: intake the idea)")?
.into_inner()
.id
}
};
let plan_id = client
.create_plan(pb::CreatePlanRequest {
idea_id: idea_id.clone(),
summary: summary.to_string(),
})
.await
.context("Funnel.CreatePlan RPC")?
.into_inner()
.id;
// Pass 1 — nodes (needs left empty: the server maps `needs` onto edges at
// add time, but a spec may reference a sibling not yet added, so ALL edges
// go through `Link` below — order-independent, same `EdgeAdded` event).
let mut server_id: std::collections::BTreeMap<&str, String> =
std::collections::BTreeMap::new();
let mut node_ids = Vec::with_capacity(nodes.len());
for n in nodes {
let id = client
.add_node(pb::AddNodeRequest {
plan_id: plan_id.clone(),
kind: n.kind.clone(),
title: n.title.clone(),
prompt: String::new(),
targets: Vec::new(),
needs: Vec::new(),
})
.await
.with_context(|| format!("Funnel.AddNode RPC (node `{}`)", n.local_id))?
.into_inner()
.id;
server_id.insert(n.local_id.as_str(), id.clone());
node_ids.push((n.local_id.clone(), id));
}
// Pass 2 — edges (dep → node), through the local→server id map.
for n in nodes {
for d in &n.needs {
client
.link(pb::LinkRequest {
plan_id: plan_id.clone(),
from: server_id[d.as_str()].clone(),
to: server_id[n.local_id.as_str()].clone(),
})
.await
.with_context(|| format!("Funnel.Link RPC (edge `{d}` → `{}`)", n.local_id))?;
}
}
Ok(PersistedPlan {
idea_id,
plan_id,
node_ids,
})
})
}
// ── the EMBEDDED pane's LIVE-source seam (host injection) ─────────────────────
/// **The embedded funnel pane's LIVE-source injection seam** — the same host-injection
/// pattern as the Warehouse Navigator's `RealFnSource`: the pane stays FAT/embedded
/// (offline-first, immediate — construction never dials), and a HOST that owns an
/// endpoint injects a source that is consulted LAZILY on each refresh/poll. A healthy
/// consult REPLACES the rendered funnel with the server's real, `funnel_events`-backed
/// state (provenance `live: …` in `state_json.funnel_source`); a dead/refused/denied
/// consult KEEPS the embedded state and NAMES the outage with the SAME distinct
/// failure vocabulary the thin pane uses (`state_json.live_status`:
/// unreachable / auth_failed / not_served / server_error / no_warehouse) — never
/// fabricated, never a silent fallback.
pub trait LiveFunnelSource: Send + Sync {
/// Fetch the real funnel for `workspace`. The error chain is classified by the
/// SAME [`classify_conn`](crate) rules the thin pane uses (a `tonic::Status` in
/// the chain names auth vs not-served vs server error; a connect failure is
/// `unreachable`).
fn fetch(&self, workspace: &str) -> Result<funnel_core::state::Funnel>;
/// Submit an idea/error into the live funnel (the WRITE leg), returning the new
/// item's id. Only consulted while the live leg is UP — an outage falls back to
/// the in-process engine's offline submit.
fn submit(&self, workspace: &str, text: &str, kind: IntakeKind) -> Result<String>;
/// Where the live funnel comes from — rides verbatim in `funnel_source`.
fn describe(&self) -> String;
}
/// **The SHIPPED live source**: nornir's `Funnel.Show` / `Funnel.SubmitIdea` over the
/// existing thin gRPC pair ([`fetch_funnel`] / [`submit_idea`]) — pure reuse of the
/// one wire path (the same `submit_idea` the dwarves MCP `funnel_submit_idea` tool
/// calls), never a second client.
pub struct GrpcFunnelSource {
endpoint: String,
token: String,
}
impl GrpcFunnelSource {
pub fn new(endpoint: impl Into<String>, token: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
token: token.into(),
}
}
}
impl LiveFunnelSource for GrpcFunnelSource {
fn fetch(&self, workspace: &str) -> Result<funnel_core::state::Funnel> {
fetch_funnel(&self.endpoint, &self.token, workspace)
}
fn submit(&self, workspace: &str, text: &str, kind: IntakeKind) -> Result<String> {
submit_idea(&self.endpoint, &self.token, workspace, text, kind)
}
fn describe(&self) -> String {
format!("nornir funnel @ {}", self.endpoint)
}
}
// ── the FAT / EMBEDDED funnel engine (the fat-mode default) ───────────────────
/// **The in-process (FAT / embedded) funnel** — the fat-mode default so dwarves
/// works self-contained with **`nornir-server` DOWN**. It maintains a
/// [`funnel_core::state::Funnel`] mutated purely in-process through funnel-core's
/// OWN event [`apply`](funnel_core::state::Funnel::apply) — the SAME engine
/// `nornir`'s funnel `Store` replays, so this is genuine reuse, not a twin — and
/// renders + grows a real Plan-DAG with **no network at all**.
///
/// The funnel is seeded over the LOCAL workspace, never the wire:
/// * if `$NORNIR_FUNNEL_LOG` points at a JSON-lines log of
/// [`funnel_core::event::Event`], it is replayed (a real local funnel);
/// * otherwise a small `nordisk` demo funnel is seeded so the embedded DAG renders
/// non-empty — mirroring the release pane's endorsed "nordisk fixture" fallback.
///
/// Submitting an idea/error [`applies`](EmbeddedFunnel::submit) real funnel-core
/// events in-process, so the DAG grows offline exactly like the live one does.
pub struct EmbeddedFunnel {
funnel: funnel_core::state::Funnel,
/// The workspace the embedded funnel is scoped to (for display/state only).
workspace: String,
}
impl EmbeddedFunnel {
/// Build the embedded funnel for `workspace` — replaying a local event log if
/// one is configured, else seeding the `nordisk` demo funnel. Never touches the
/// network; never panics.
pub fn new(workspace: impl Into<String>) -> Self {
let workspace = workspace.into();
let mut funnel = funnel_core::state::Funnel::default();
if let Some(path) = local_funnel_log() {
if replay_log(&mut funnel, &path).is_ok() && !funnel.ideas.is_empty() {
funnel.promote_ready();
return Self { funnel, workspace };
}
// A partial/failed replay must not leave a half-built funnel — reset and
// fall through to the self-contained seed.
funnel = funnel_core::state::Funnel::default();
}
// NO seed: with no local log the embedded funnel is EMPTY. It used to be filled
// with a sample idea and its plan, which a person looking at a pane whose server
// was down could not tell from their own funnel.
Self { funnel, workspace }
}
/// The embedded funnel over the SAMPLE content ([`seed_nordisk_funnel`]) — tests
/// only (`testkit`): no shipped build contains it.
#[cfg(any(test, feature = "testkit"))]
pub fn seeded(workspace: impl Into<String>) -> Self {
let mut funnel = funnel_core::state::Funnel::default();
seed_nordisk_funnel(&mut funnel);
funnel.promote_ready();
Self {
funnel,
workspace: workspace.into(),
}
}
/// The workspace the embedded funnel is scoped to.
pub fn workspace(&self) -> &str {
&self.workspace
}
/// The current in-process funnel (readiness promoted), for rendering.
fn funnel(&self) -> funnel_core::state::Funnel {
let mut f = self.funnel.clone();
f.promote_ready();
f
}
/// **The EVENT-SOURCED run history** — every `RunRecorded` folded into
/// `funnel_core::Funnel::runs`, newest first, as the wire shape the pane
/// renders.
///
/// This is the funnel's OWN history, projected by funnel-core, not a
/// second store kept alongside it: the state engine is the one source, so a
/// replayed log (`$NORNIR_FUNNEL_LOG`) yields exactly the history the engine
/// folded. Distinct from `FunnelRunLog`, which snapshots the *rendered DAG*
/// per poll for the run PICKER (a view concern, and the only history a remote
/// `Funnel.Show` can support — that RPC returns the current funnel with no
/// run ledger on the wire).
pub fn runs_json(&self) -> serde_json::Value {
self.funnel.runs_to_json()
}
/// How many runs the event log recorded.
pub fn run_count(&self) -> usize {
self.funnel.runs().len()
}
/// **Submit an idea/error into the in-process funnel (the offline WRITE leg).**
/// Applies real funnel-core events (idea → one-node plan, node kind from
/// [`ItemKind::node_kind`](funnel_core::event::ItemKind::node_kind)) so the DAG
/// grows locally, and returns the new idea id. No server involved.
fn submit(&mut self, text: &str, kind: IntakeKind) -> Result<String> {
use funnel_core::event::{Event, ItemKind};
use funnel_core::ids::{IdeaId, NodeId, PlanId};
let item_kind = match kind {
IntakeKind::Idea => ItemKind::Idea,
IntakeKind::Error => ItemKind::Error,
};
let ts = chrono::Utc::now();
let iid = IdeaId::new(format!("i-{:03}", self.funnel.next_idea));
self.funnel.apply(&Event::IdeaSubmitted {
id: iid.clone(),
source: "dwarves:funnel-pane(embedded)".into(),
text: text.to_string(),
refs: Vec::new(),
item_kind,
ts,
})?;
let pid = PlanId::new(format!("p-{:03}", self.funnel.next_plan));
self.funnel.apply(&Event::PlanCreated {
id: pid.clone(),
idea_id: iid.clone(),
summary: text.chars().take(60).collect::<String>(),
planner: "embedded".into(),
ts,
})?;
let nid = NodeId::new(format!("n-{:03}", self.funnel.next_node));
self.funnel.apply(&Event::NodeAdded {
plan_id: pid,
node_id: nid,
kind: item_kind.node_kind().to_string(),
params: serde_json::Map::new(),
targets: Vec::new(),
prompt_excerpt: None,
ts,
})?;
self.funnel.promote_ready();
Ok(iid.as_str().to_string())
}
}
/// The optional local funnel event-log path (`$NORNIR_FUNNEL_LOG`), when it names an
/// existing file. Kept env-only so the embedded default pulls in no heavy warehouse
/// deps — a real local funnel is opt-in, the `nordisk` seed is the zero-config default.
fn local_funnel_log() -> Option<std::path::PathBuf> {
let p = std::env::var_os("NORNIR_FUNNEL_LOG")?;
let p = std::path::PathBuf::from(p);
p.is_file().then_some(p)
}
/// Replay a JSON-lines log of [`funnel_core::event::Event`] into `funnel` (one event
/// per line; blank lines skipped; a line that fails to parse/apply is skipped rather
/// than aborting the whole replay — defensive, exactly like nornir's loader).
fn replay_log(funnel: &mut funnel_core::state::Funnel, path: &std::path::Path) -> Result<()> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read funnel log {}", path.display()))?;
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(ev) = serde_json::from_str::<funnel_core::event::Event>(line) {
let _ = funnel.apply(&ev);
}
}
Ok(())
}
/// A small SAMPLE funnel for tests (`testkit`): one feature idea decomposed into a
/// write→fix→test chain, plus a triaged error item — built from the SAME funnel-core
/// events the live engine records. Not compiled into a shipped build: an embedded pane
/// with no local log is empty.
#[cfg(any(test, feature = "testkit"))]
fn seed_nordisk_funnel(funnel: &mut funnel_core::state::Funnel) {
use funnel_core::event::{Event, ItemKind, NodeStatus};
use funnel_core::ids::{IdeaId, NodeId, PlanId};
let ts = chrono::Utc::now();
let mut apply = |ev: Event| {
let _ = funnel.apply(&ev);
};
// ── idea: the release engine — decomposed into a write → fix → test chain ──
let i1 = IdeaId::new("i-001");
apply(Event::IdeaSubmitted {
id: i1.clone(),
source: "human:alice".into(),
text: "world-class release engine".into(),
refs: Vec::new(),
item_kind: ItemKind::Idea,
ts,
});
let p1 = PlanId::new("p-001");
apply(Event::PlanCreated {
id: p1.clone(),
idea_id: i1,
summary: "decompose the release engine".into(),
planner: "embedded".into(),
ts,
});
let n_write = NodeId::new("n-001");
let n_fix = NodeId::new("n-002");
let n_test = NodeId::new("n-003");
for (nid, kind) in [
(&n_write, "code:write"),
(&n_fix, "code:fix"),
(&n_test, "test:write"),
] {
apply(Event::NodeAdded {
plan_id: p1.clone(),
node_id: nid.clone(),
kind: kind.into(),
params: serde_json::Map::new(),
targets: Vec::new(),
prompt_excerpt: None,
ts,
});
}
apply(Event::EdgeAdded {
plan_id: p1.clone(),
from_node: n_write.clone(),
to_node: n_fix.clone(),
ts,
});
apply(Event::EdgeAdded {
plan_id: p1.clone(),
from_node: n_fix.clone(),
to_node: n_test.clone(),
ts,
});
apply(Event::NodeStatusChanged {
plan_id: p1.clone(),
node_id: n_write.clone(),
status: NodeStatus::Done,
why: None,
ts,
});
apply(Event::NodeStatusChanged {
plan_id: p1.clone(),
node_id: n_fix.clone(),
status: NodeStatus::InProgress,
why: None,
ts,
});
// Real execution history, as EVENTS — the write node ran once and passed, the
// fix node ran twice (a failure, then a retry still in flight). These fold into
// `funnel_core::Funnel::runs`, which is what the pane's run-history strip reads;
// the seed carries them so the offline demo shows a non-empty history without a
// server (the same events a live engine records — no fixture-only shape).
use funnel_core::event::RunOutcome as CoreRunOutcome;
use funnel_core::ids::RunId;
apply(Event::RunRecorded {
plan_id: p1.clone(),
node_id: n_write.clone(),
run_id: RunId::new("r-001"),
ran_by: "agent:brokkr".into(),
outcome: CoreRunOutcome::Ok,
log_ref: None,
produced_commits: Vec::new(),
produced_test_runs: Vec::new(),
ts,
});
apply(Event::RunRecorded {
plan_id: p1.clone(),
node_id: n_fix.clone(),
run_id: RunId::new("r-002"),
ran_by: "agent:brokkr".into(),
outcome: CoreRunOutcome::Failed,
log_ref: None,
produced_commits: Vec::new(),
produced_test_runs: Vec::new(),
ts,
});
apply(Event::RunRecorded {
plan_id: p1,
node_id: n_fix,
run_id: RunId::new("r-003"),
ran_by: "human:alice".into(),
outcome: CoreRunOutcome::Aborted,
log_ref: None,
produced_commits: Vec::new(),
produced_test_runs: Vec::new(),
ts,
});
// ── error item: a first-class funnel entry, triaged into a fix node ──
let i2 = IdeaId::new("i-002");
apply(Event::IdeaSubmitted {
id: i2.clone(),
source: "human:alice".into(),
text: "funnel defaulted to remote nornir".into(),
refs: Vec::new(),
item_kind: ItemKind::Error,
ts,
});
let p2 = PlanId::new("p-002");
apply(Event::PlanCreated {
id: p2.clone(),
idea_id: i2,
summary: "make the funnel fat-by-default".into(),
planner: "embedded".into(),
ts,
});
apply(Event::NodeAdded {
plan_id: p2,
node_id: NodeId::new("n-004"),
kind: ItemKind::Error.node_kind().into(),
params: serde_json::Map::new(),
targets: Vec::new(),
prompt_excerpt: None,
ts,
});
}
// ── funnel-core PlanDag → facett-plandag model (mirrors nornir funnel_plandag) ─
/// Map the neutral DAG status onto the plandag's operator status.
fn op_status(s: DagStatus) -> facett_plandag::OpStatus {
use facett_plandag::OpStatus;
match s {
DagStatus::Idle => OpStatus::Idle,
DagStatus::Running => OpStatus::Running,
DagStatus::Done => OpStatus::Done,
DagStatus::Failed => OpStatus::Failed,
}
}
/// Build a [`PlanDagView`] from an assembled [`PlanDag`]. The three neutral node
/// scalars map onto the plandag's Time/Memory/Rows cost-heat: **staleness → Time**,
/// **weight → Memory**, **fan-out → Rows** (same mapping as nornir's funnel_plandag).
fn view_from_dag(dag: &PlanDag) -> PlanDagView {
let idx: std::collections::HashMap<&str, usize> = dag
.nodes
.iter()
.enumerate()
.map(|(i, n)| (n.id.as_str(), i))
.collect();
let nodes: Vec<PlanNode> = dag
.nodes
.iter()
.map(|n| {
let mut pn = PlanNode::new(n.id.clone(), n.title.clone())
.status(op_status(n.status))
.metrics(Metrics {
time_ms: n.staleness_hours,
peak_mem: n.weight_bytes,
rows: n.fanout,
spill: 0,
});
if let Some(g) = &n.group {
pn = pn.group(g.clone());
}
if n.is_bug {
pn = pn.photon();
}
pn
})
.collect();
let edges: Vec<PlanEdge> = dag
.edges
.iter()
.filter_map(|e| {
let (Some(&from), Some(&to)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str()))
else {
return None;
};
Some(PlanEdge { from, to })
})
.collect();
let version = PlanVersion::new("funnel", nodes, edges);
view_from_model(PlanModel::new(version))
}
/// Build the funnel's [`PlanDagView`] from an already-assembled [`PlanModel`] — the
/// one place the pane names the view, so the live path and a re-rendered past-run
/// snapshot are byte-identical (same title, same renderer).
fn view_from_model(model: PlanModel) -> PlanDagView {
PlanDagView::new("funnel-plandag", model)
}
// ── the embeddable Panel ────────────────────────────────────────────────────
// ── growth history ("addera historik") ────────────────────────────────────────
/// One recorded funnel snapshot: the DAG's size at a poll tick. Robot-observable
/// (rides in `state_json.history`) so a test can watch the trail grow.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FunnelSample {
pub node_count: usize,
pub edge_count: usize,
}
/// Cap on the growth-history ring — a long live session can't leak memory.
pub const HISTORY_CAP: usize = 64;
/// **Bounded, de-duplicated growth history of the funnel's DAG size.** Each
/// SUCCESSFUL poll records the (node, edge) counts; a consecutive duplicate collapses
/// so the trail shows only the moments the DAG actually *changed* (grew) — the
/// "addera historik" trajectory the live pane draws under the current frame. Capped at
/// [`HISTORY_CAP`] (oldest dropped). Pure (no clock, no network) ⇒ unit-testable.
#[derive(Clone, Debug, Default)]
pub struct FunnelHistory {
samples: Vec<FunnelSample>,
}
impl FunnelHistory {
/// Record a snapshot. Returns `true` if it was appended, `false` if it collapsed
/// into the previous (identical) sample. Keeps at most [`HISTORY_CAP`] samples.
pub fn record(&mut self, node_count: usize, edge_count: usize) -> bool {
let s = FunnelSample {
node_count,
edge_count,
};
if self.samples.last() == Some(&s) {
return false;
}
self.samples.push(s);
if self.samples.len() > HISTORY_CAP {
let overflow = self.samples.len() - HISTORY_CAP;
self.samples.drain(0..overflow);
}
true
}
/// Number of recorded (change) samples.
pub fn len(&self) -> usize {
self.samples.len()
}
/// True when nothing has been recorded yet.
pub fn is_empty(&self) -> bool {
self.samples.is_empty()
}
/// The recorded samples, oldest→newest.
pub fn samples(&self) -> &[FunnelSample] {
&self.samples
}
/// The most recently recorded node count (0 if the trail is empty).
pub fn latest_node_count(&self) -> usize {
self.samples.last().map(|s| s.node_count).unwrap_or(0)
}
/// Largest node count ever recorded (the peak of the trail).
pub fn peak_nodes(&self) -> usize {
self.samples.iter().map(|s| s.node_count).max().unwrap_or(0)
}
/// Net node growth from the first recorded sample to the latest (can be negative
/// if the plan shrank; usually the DAG only grows).
pub fn node_growth(&self) -> i64 {
match (self.samples.first(), self.samples.last()) {
(Some(a), Some(b)) => b.node_count as i64 - a.node_count as i64,
_ => 0,
}
}
/// Drop the whole trail (the pane's "clear history" control).
pub fn clear(&mut self) {
self.samples.clear();
}
/// The trail as `state_json` data: an array of `{node_count,edge_count}`, oldest→newest.
pub fn to_json(&self) -> serde_json::Value {
serde_json::Value::Array(
self.samples
.iter()
.map(|s| serde_json::json!({ "node_count": s.node_count, "edge_count": s.edge_count }))
.collect(),
)
}
}
// ── run history ("addera historik", the NEXT layer) ───────────────────────────
/// The outcome of a captured funnel run, derived from its DAG's node statuses:
/// any Failed ⇒ `Failed`; else every node Done ⇒ `Done`; else `Running`. Robot-
/// observable (rides in `state_json.runs[].outcome`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunOutcome {
Running,
Done,
Failed,
}
impl RunOutcome {
/// A short, stable JSON/UI token.
pub fn as_str(self) -> &'static str {
match self {
RunOutcome::Running => "running",
RunOutcome::Done => "done",
RunOutcome::Failed => "failed",
}
}
}
/// Derive the run outcome from a projected [`PlanDag`]'s node statuses. An empty DAG
/// is `Running` (nothing done yet). Pure ⇒ unit-testable.
fn run_outcome_from_dag(dag: &PlanDag) -> RunOutcome {
let mut any = false;
let mut any_failed = false;
let mut all_done = true;
for n in &dag.nodes {
any = true;
match n.status {
DagStatus::Failed => any_failed = true,
DagStatus::Done => {}
_ => all_done = false,
}
}
if any_failed {
RunOutcome::Failed
} else if any && all_done {
RunOutcome::Done
} else {
RunOutcome::Running
}
}
/// One recorded funnel **run**: a full point-in-time snapshot of the plan-DAG the pane
/// can re-select and re-draw (distinct from [`FunnelHistory`], which keeps only the
/// size trajectory). Carries the captured [`PlanModel`] (the DAG itself, Clone) so a
/// selected past run renders through the SAME [`facett_plandag::PlanDagView`] as the
/// live view — no second renderer (L5).
#[derive(Clone, Debug)]
pub struct FunnelRun {
/// 1-based capture sequence (stable id the picker + `state_json` address).
pub seq: usize,
pub node_count: usize,
pub edge_count: usize,
pub outcome: RunOutcome,
/// egui-input time (seconds) when captured (0.0 if none supplied). Started marker.
pub captured_at_s: f64,
/// The captured DAG snapshot — re-rendered on selection via `PlanDagView::new`.
pub model: PlanModel,
}
impl FunnelRun {
/// The run summary AS DATA (rides in `state_json.runs`), WITHOUT the heavy model.
fn summary_json(&self) -> serde_json::Value {
serde_json::json!({
"seq": self.seq,
"node_count": self.node_count,
"edge_count": self.edge_count,
"outcome": self.outcome.as_str(),
"captured_at_s": self.captured_at_s,
})
}
}
/// Cap on the run-history ring — a long live session can't leak the (heavier, model-
/// bearing) run snapshots.
pub const RUN_CAP: usize = 32;
/// **Bounded, de-duplicated log of past funnel runs** ("addera historik", the run
/// dimension). Each SUCCESSFUL poll whose DAG *changed* (node/edge count or outcome
/// differs from the previous capture) records a full snapshot; a duplicate collapses
/// so the log holds only distinct run states. Capped at [`RUN_CAP`] (oldest dropped).
/// Pure (no clock, no network) ⇒ unit-testable.
#[derive(Clone, Debug, Default)]
pub struct FunnelRunLog {
runs: Vec<FunnelRun>,
/// Monotonic seq counter (never reused, even after the ring drops old entries).
next_seq: usize,
}
impl FunnelRunLog {
/// Record a run snapshot. Returns `Some(seq)` if appended, `None` if it collapsed
/// into the previous (same node/edge count + outcome) capture. The model's initial
/// version supplies the node/edge counts (self-contained — no external counts).
pub fn record(&mut self, model: PlanModel, outcome: RunOutcome, at_s: f64) -> Option<usize> {
let node_count = model.initial.nodes.len();
let edge_count = model.initial.edges.len();
if let Some(last) = self.runs.last() {
if last.node_count == node_count
&& last.edge_count == edge_count
&& last.outcome == outcome
{
return None;
}
}
self.next_seq += 1;
let seq = self.next_seq;
self.runs.push(FunnelRun {
seq,
node_count,
edge_count,
outcome,
captured_at_s: at_s,
model,
});
if self.runs.len() > RUN_CAP {
let overflow = self.runs.len() - RUN_CAP;
self.runs.drain(0..overflow);
}
Some(seq)
}
/// Number of recorded runs.
pub fn len(&self) -> usize {
self.runs.len()
}
/// True when no run has been recorded yet.
pub fn is_empty(&self) -> bool {
self.runs.is_empty()
}
/// The recorded runs, oldest→newest.
pub fn runs(&self) -> &[FunnelRun] {
&self.runs
}
/// Look up a run by its stable `seq`.
pub fn get(&self, seq: usize) -> Option<&FunnelRun> {
self.runs.iter().find(|r| r.seq == seq)
}
/// Drop the whole log (the pane's "clear runs" control).
pub fn clear(&mut self) {
self.runs.clear();
}
/// The run summaries as `state_json` data (oldest→newest, each WITHOUT the model).
pub fn to_json(&self) -> serde_json::Value {
serde_json::Value::Array(self.runs.iter().map(|r| r.summary_json()).collect())
}
}
/// Connection outcome of the last fetch (drives the status line + `state_json`).
///
/// The failure arms are DISTINCT on purpose (the funnel-offline mislabel fix): the
/// pane must never claim *"server offline"* when the server is actually up. A funnel
/// fetch can fail for four very different reasons and the UI + `state_json` name each
/// one so a human (and a robot) can tell them apart:
/// * [`NoWarehouse`](Conn::NoWarehouse) — nothing is SELECTED to fetch (empty
/// workspace). The server is irrelevant; pick a warehouse.
/// * [`Unreachable`](Conn::Unreachable) — the server could not be connected to at all
/// (wrong port / not running).
/// * [`AuthFailed`](Conn::AuthFailed) — connected, but the bearer token was missing or
/// rejected (`Unauthenticated` / `PermissionDenied`).
/// * [`NotServed`](Conn::NotServed) — connected + authed, but the server does not serve
/// the selected warehouse (`NotFound`).
/// * [`ServerError`](Conn::ServerError) — any other RPC/server error.
#[derive(Clone, Debug, PartialEq, Eq)]
enum Conn {
/// Never fetched yet.
Idle,
/// The server returned a funnel (possibly empty).
Loaded,
/// No warehouse is selected (workspace empty) — nothing to fetch. NOT a server
/// problem; the pane guides the user to select one.
NoWarehouse,
/// The nornir-server could not be reached at all (connect failed).
Unreachable,
/// Reached the server, but the bearer token was missing or rejected.
AuthFailed,
/// Reached + authed, but the server does not serve the selected warehouse.
NotServed,
/// Any other RPC/server error (a clean, non-panicking state).
ServerError,
}
/// **Classify a failed fetch into a distinct [`Conn`] state** — the crux of the
/// funnel-offline mislabel fix. Never returns [`Conn::Loaded`]/[`Conn::Idle`]/
/// [`Conn::NoWarehouse`] (those are decided before the fetch). Downcasts the anyhow
/// error chain to a [`tonic::Status`] to read the gRPC code (auth vs not-served vs
/// other); if there is none, a connect-context error is [`Conn::Unreachable`], else
/// [`Conn::ServerError`]. Pure ⇒ unit-testable.
fn classify_conn(e: &anyhow::Error) -> Conn {
for cause in e.chain() {
if let Some(st) = cause.downcast_ref::<tonic::Status>() {
use tonic::Code;
return match st.code() {
Code::Unauthenticated | Code::PermissionDenied => Conn::AuthFailed,
Code::NotFound => Conn::NotServed,
_ => Conn::ServerError,
};
}
}
let msg = format!("{e:#}").to_ascii_lowercase();
if msg.contains("connect to nornir-server")
|| msg.contains("tcp connect")
|| msg.contains("transport error")
{
Conn::Unreachable
} else {
Conn::ServerError
}
}
/// The LIVE funnel pane: fetches nornir-server's funnel over thin gRPC and renders
/// the reconstructed Plan-DAG with [`facett_plandag`]. A [`Facet`] (hence a [`Panel`]
/// via the blanket impl) a host mounts in a tab.
pub struct FunnelPanel {
/// The FAT / EMBEDDED engine — `Some` ⇒ the fat-mode default (in-process funnel,
/// **no network**, `nornir-server` may be DOWN); `None` ⇒ thin/remote (gRPC to an
/// explicitly-configured `--server`/`NORNIR_SERVER`). Embedded is the default so
/// the pane renders + polls its own DAG with no server running — never the old
/// "no remote nornir"/unreachable error.
embedded: Option<EmbeddedFunnel>,
/// HOST-injected LIVE source (embedded mode only — [`LiveFunnelSource`]): consulted
/// lazily on each refresh/poll. `None` ⇒ purely local (the classic embedded pane).
live: Option<std::sync::Arc<dyn LiveFunnelSource>>,
/// Outcome of the LAST live consult: `None` = not consulted yet (lazy — nothing
/// dialed at construction); `Some(Conn::Loaded)` = the rendered funnel IS the
/// server's; any other `Some(_)` = a NAMED outage (the embedded state is shown).
live_conn: Option<Conn>,
/// The last live consult's error display text (state_json + hover — honesty).
live_error: Option<String>,
/// **A live consult IN FLIGHT** (the worker's receiver + when it started).
///
/// The consult is a full gRPC dial + `Funnel.Show` round trip
/// ([`fetch_funnel`] → `block_on`), and [`Panel::ui`] used to run it INLINE on
/// every auto-poll tick. The frame that carried the poll could not paint until
/// the server answered — and against a dead endpoint it held for the whole 3 s
/// connect timeout, every interval, forever.
///
/// MEASURED (oden, 2026-08-27, `dwarves-gui/tests/robot_funnel_off_thread.rs`
/// driving the REAL `Panel::ui` through the robot harness with a `SlowLive`
/// source standing in for the gRPC consult at a deliberate 1 500 ms; the number
/// is the wall clock of the frame that carries the auto-poll):
///
/// ```text
/// inline: 1 508.97 ms per polling frame — 100.6% of the consult; the window cannot paint
/// off-thread: 2.43 ms per polling frame — 0.2% of it; the worker is started and the frame returns
/// ```
///
/// 620x off the polling frame, and — the actual invariant — independent of what
/// the consult costs. The 3 s connect timeout against a DEAD endpoint was the
/// worst arm of the same defect: it froze the window for the full timeout, every
/// interval, for as long as the pane was open.
///
/// The frame polls this ([`Self::poll_live`]); the scripted/robot seam blocks on
/// it ([`Self::wait_live`]). A refresh pressed while one is in flight is not a
/// second round trip.
live_pending: Option<(
std::sync::mpsc::Receiver<Result<funnel_core::state::Funnel>>,
std::time::Instant,
)>,
endpoint: String,
token: String,
workspace: String,
view: Option<PlanDagView>,
conn: Conn,
node_count: usize,
edge_count: usize,
/// The last error's display text (folded into `state_json` for tests).
error: Option<String>,
/// LIVE auto-poll: when on, the pane re-fetches `Funnel.Show` every
/// `poll_interval_s` so the plan-DAG **grows on its own** as ideas land (no
/// manual Refresh needed). On by default.
auto_poll: bool,
/// Seconds between auto-poll re-fetches.
poll_interval_s: f64,
/// egui-input time (seconds) of the last (auto or manual) fetch; the poll is
/// due once `now - last_poll_s >= poll_interval_s`.
last_poll_s: f64,
/// The submit strip's text buffer (the idea/error you type into the pane).
draft: String,
/// Which intake kind the submit strip is set to (idea | error).
intake_kind: IntakeKind,
/// Outcome of the last submit, folded into `state_json` for robot assertions:
/// `Some(Ok(id))` = accepted, `Some(Err(msg))` = rejected, `None` = none yet.
last_submit: Option<std::result::Result<String, String>>,
/// LIVE growth trail ("addera historik"): the DAG's (node,edge) size recorded on
/// every successful poll (consecutive duplicates collapsed), so the pane draws the
/// funnel's growth trajectory under the current frame — not just the latest state.
history: FunnelHistory,
/// RUN HISTORY ("addera historik", the run dimension): a de-duplicated log of past
/// funnel runs, each a full DAG snapshot the user can re-select to view a prior
/// run's final plan. The live view stays the default ("latest").
runs: FunnelRunLog,
/// The selected past run's `seq` (`None` ⇒ the live/latest view is shown).
selected_run: Option<usize>,
/// The re-rendered DAG of the selected past run (rebuilt on selection from the
/// run's `model`), so a historical run draws through the SAME `PlanDagView`.
snapshot_view: Option<PlanDagView>,
/// The latest egui-input time (seconds), captured at the top of `ui()`; stamps a
/// recorded run's `captured_at_s` on the next poll. 0.0 until the pane renders.
now_s: f64,
/// The Claude credential SOURCE, as reported by the host over
/// `{"claude_source": "..."}` (e.g. `env:ANTHROPIC_API_KEY`, `vault:anthropic`,
/// `none`). The funnel drives Claude to expand ideas, so the pane shows whether a
/// credential is configured and points at Settings when it is not.
///
/// A SOURCE LABEL ONLY — this crate never reads, stores or renders key material,
/// and has no vault dependency. The host resolves it (dwarves does so via
/// `dwarves::claude_token::status`) and pushes the label down.
claude_source: String,
/// The SAME DAG as [`view`](Self::view), rendered as a node-link graph on the
/// shared `facett-graphview` engine. Rebuilt on every successful poll from the
/// same `PlanDag`, so the two renders never disagree about the funnel.
graph: FunnelGraphPane,
/// The SAME DAG rendered as a **3D layered graph** — a camera-only 3D placement
/// (ranks stacked in space) with an optional auto-rotate + clickable drill. Rebuilt
/// (cached) on every successful poll from the same `PlanDag`, so all three renders
/// agree about the funnel.
graph3d: FunnelGraph3d,
/// Which render of the DAG is on screen — the card lane, the flat graph, or the 3D graph.
dag_view: FunnelDagView,
}
/// Which render of the funnel DAG the pane is showing. Two renders, one DAG: the
/// card lane (`facett-plandag`, metric-bearing) answers *what state is each node in*;
/// the graph (`facett-graphview`) answers *what needs what*.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FunnelDagView {
/// The `facett-plandag` metric-bearing card lane (the default).
Cards,
/// The `facett-graphview` node-link graph (flat 2D).
Graph,
/// The 3D layered node-link graph (camera-only 3D + optional auto-rotate).
Graph3d,
}
impl FunnelDagView {
fn key(self) -> &'static str {
match self {
FunnelDagView::Cards => "cards",
FunnelDagView::Graph => "graph",
FunnelDagView::Graph3d => "graph3d",
}
}
fn parse(s: &str) -> Option<Self> {
Some(match s {
"cards" | "plandag" => FunnelDagView::Cards,
"graph" | "graphview" => FunnelDagView::Graph,
"graph3d" | "3d" | "graph_3d" => FunnelDagView::Graph3d,
_ => return None,
})
}
}
/// Default seconds between live auto-poll re-fetches of the funnel.
pub const DEFAULT_POLL_INTERVAL_S: f64 = 1.5;
impl FunnelPanel {
/// The EVENT-SOURCED run history (newest first) — funnel-core's own
/// `RunRecorded` ledger, projected by [`EmbeddedFunnel::runs_json`]. An empty
/// array in remote mode (the `Funnel.Show` RPC carries no run ledger).
pub fn event_runs(&self) -> serde_json::Value {
self.embedded
.as_ref()
.map(|e| e.runs_json())
.unwrap_or_else(|| serde_json::Value::Array(Vec::new()))
}
/// How many runs the event log recorded (0 in remote mode).
pub fn event_run_count(&self) -> usize {
self.embedded.as_ref().map(|e| e.run_count()).unwrap_or(0)
}
/// The Claude credential source label the host reported.
pub fn claude_source(&self) -> &str {
&self.claude_source
}
/// Set the Claude credential source label (the host's resolved status).
/// A blank value normalises to `none` so the seam never carries an empty label.
pub fn set_claude_source(&mut self, source: impl Into<String>) {
let s = source.into();
self.claude_source = if s.trim().is_empty() {
"none".to_string()
} else {
s
};
}
}
impl FunnelPanel {
/// Build the pane for `endpoint` and eagerly attempt the first fetch. A failed
/// fetch is captured as the `Unavailable` state (never a panic).
pub fn new(
endpoint: impl Into<String>,
token: impl Into<String>,
workspace: impl Into<String>,
) -> Self {
let mut p = Self {
embedded: None,
live: None,
live_conn: None,
live_error: None,
live_pending: None,
endpoint: endpoint.into(),
token: token.into(),
workspace: workspace.into(),
view: None,
conn: Conn::Idle,
node_count: 0,
edge_count: 0,
error: None,
auto_poll: true,
poll_interval_s: DEFAULT_POLL_INTERVAL_S,
last_poll_s: 0.0,
draft: String::new(),
intake_kind: IntakeKind::Idea,
last_submit: None,
history: FunnelHistory::default(),
runs: FunnelRunLog::default(),
selected_run: None,
snapshot_view: None,
now_s: 0.0,
claude_source: "none".to_string(),
graph: FunnelGraphPane::new(),
graph3d: FunnelGraph3d::new(),
dag_view: FunnelDagView::Cards,
};
p.refresh();
p
}
/// [`new_embedded`](Self::new_embedded) over the SAMPLE funnel — tests only
/// (`testkit`): the pane a test drives when it needs a non-empty Plan-DAG.
#[cfg(any(test, feature = "testkit"))]
pub fn new_embedded_seeded(workspace: impl Into<String>) -> Self {
let workspace = workspace.into();
let mut p = Self::new_embedded(workspace.clone());
p.embedded = Some(EmbeddedFunnel::seeded(workspace));
p.refresh();
p
}
/// **Build the pane in FAT / EMBEDDED mode (the default)** — an in-process funnel
/// engine over the local workspace, **no `nornir-server`**. The pane renders +
/// polls its own Plan-DAG with no server running (the fat-mode doctrine: dwarves is
/// self-contained; nornir-server can be DOWN). Only [`new`](Self::new) /
/// [`panel_with`] opt into thin/remote gRPC, and only when a real endpoint is set.
pub fn new_embedded(workspace: impl Into<String>) -> Self {
let workspace = workspace.into();
let mut p = Self {
embedded: Some(EmbeddedFunnel::new(workspace.clone())),
live: None,
live_conn: None,
live_error: None,
live_pending: None,
endpoint: "embedded (local · no server)".to_string(),
token: String::new(),
workspace,
view: None,
conn: Conn::Idle,
node_count: 0,
edge_count: 0,
error: None,
auto_poll: true,
poll_interval_s: DEFAULT_POLL_INTERVAL_S,
last_poll_s: 0.0,
draft: String::new(),
intake_kind: IntakeKind::Idea,
last_submit: None,
history: FunnelHistory::default(),
runs: FunnelRunLog::default(),
selected_run: None,
snapshot_view: None,
now_s: 0.0,
claude_source: "none".to_string(),
graph: FunnelGraphPane::new(),
graph3d: FunnelGraph3d::new(),
dag_view: FunnelDagView::Cards,
};
p.refresh();
p
}
/// True when the pane is in the FAT / EMBEDDED (no-network) mode.
pub fn is_embedded(&self) -> bool {
self.embedded.is_some()
}
/// **Attach a HOST-injected live source** (the [`LiveFunnelSource`] seam) to an
/// embedded pane. LAZY on purpose: nothing is dialed here — the next refresh
/// (the auto-poll tick, a manual ⟳, a submit's re-fetch, a workspace re-bind)
/// consults it, so construction stays immediate/offline-first even against a
/// dead or slow endpoint (the Warehouse Navigator's injection posture).
pub fn with_live_source(mut self, source: Box<dyn LiveFunnelSource>) -> Self {
// Taken as a `Box` (every caller's spelling is unchanged) and HELD as an
// `Arc` — which is what the off-thread consult worker clones.
self.live = Some(source.into());
self.live_conn = None;
self.live_error = None;
self
}
/// True when the rendered funnel is the LIVE server's (embedded pane whose last
/// live consult loaded) — the provenance the submit routing keys on.
fn live_loaded(&self) -> bool {
self.embedded.is_some() && self.live_conn == Some(Conn::Loaded)
}
/// **Submit the current draft** as an idea/error into nornir's funnel, then
/// re-fetch so the pane's DAG immediately reflects the new item. A blank draft is
/// a no-op. The outcome (accepted id or error text) is recorded in `last_submit`
/// for the status line + `state_json`. Never panics — an offline server is
/// captured as a rejected submit, exactly like a failed fetch.
pub fn submit_draft(&mut self) {
let text = self.draft.trim().to_string();
if text.is_empty() {
return;
}
// EMBEDDED + LIVE, leg UP: the submit rides the SAME wire the MCP write leg
// uses (`Funnel.SubmitIdea` via the injected source), so the idea lands in
// the server's funnel_events-backed store — and the refresh below re-pulls
// the live funnel, making it VISIBLE at once (submit → visible). A rejected
// wire submit is recorded honestly, never silently retried locally (that
// would show "submitted" for an idea the server does not hold).
if self.live_loaded() {
if let Some(src) = &self.live {
match src.submit(&self.workspace, &text, self.intake_kind) {
Ok(id) => {
self.last_submit = Some(Ok(id));
self.draft.clear();
// SUBMIT -> VISIBLE is the contract this leg exists for, so the
// re-pull is the BLOCKING one: the idea must be on screen when
// the submit returns, not one poll interval later. The submit
// itself already went over the wire on this thread — waiting for
// its read-back adds no freeze the write did not already have.
self.refresh_blocking();
}
Err(e) => {
self.last_submit = Some(Err(format!("{e:#}")));
}
}
return;
}
}
// FAT / EMBEDDED (default, or the live leg is DOWN): apply the idea/error to
// the in-process funnel — no network, so it grows the DAG offline exactly
// like the live server would.
if let Some(emb) = &mut self.embedded {
match emb.submit(&text, self.intake_kind) {
Ok(id) => {
self.last_submit = Some(Ok(id));
self.draft.clear();
self.refresh();
}
Err(e) => {
self.last_submit = Some(Err(format!("{e:#}")));
}
}
return;
}
match submit_idea(
&self.endpoint,
&self.token,
&self.workspace,
&text,
self.intake_kind,
) {
Ok(id) => {
self.last_submit = Some(Ok(id));
self.draft.clear();
// Grow the DAG now, don't wait for the next poll tick.
self.refresh();
}
Err(e) => {
self.last_submit = Some(Err(format!("{e:#}")));
}
}
}
/// True when auto-poll is on AND at least one interval has elapsed since the
/// last fetch (`now_s` = egui input time). Pure/deterministic → unit-testable
/// without a clock or the network.
pub fn poll_due(&self, now_s: f64) -> bool {
self.auto_poll && (now_s - self.last_poll_s) >= self.poll_interval_s
}
/// Turn live auto-poll on/off (the ● live / ⏸ paused toggle).
pub fn set_auto_poll(&mut self, on: bool) {
self.auto_poll = on;
}
/// The growth-history trail ("addera historik"), oldest→newest.
pub fn history(&self) -> &FunnelHistory {
&self.history
}
/// The run history log (past funnel runs), oldest→newest.
pub fn runs(&self) -> &FunnelRunLog {
&self.runs
}
/// **Capture a plan-DAG snapshot as a run** — the exact entry [`refresh`] uses on a
/// live poll (de-duped by node/edge count + outcome). Exposed so a host, or a test
/// without a live server, can feed the run history. Returns the new `seq` (or `None`
/// if it collapsed into the previous capture).
pub fn record_run(
&mut self,
model: PlanModel,
outcome: RunOutcome,
at_s: f64,
) -> Option<usize> {
self.runs.record(model, outcome, at_s)
}
/// **Select a past run to view** (`Some(seq)`) or return to the live/latest view
/// (`None`). Selecting a known run rebuilds its DAG snapshot through a fresh
/// [`PlanDagView`]; an unknown seq is ignored (stays on whatever was shown). This
/// is the run-picker's handler — the headless `{"select_run": …}` seam drives it.
pub fn select_run(&mut self, seq: Option<usize>) {
match seq {
Some(s) => {
if let Some(run) = self.runs.get(s) {
self.selected_run = Some(s);
self.snapshot_view = Some(view_from_model(run.model.clone()));
}
}
None => {
self.selected_run = None;
self.snapshot_view = None;
}
}
}
/// The plan-DAG view currently shown: the selected snapshot when a past run is
/// picked, else the live view. `None` when neither exists (offline, no selection).
fn current_view(&self) -> Option<&PlanDagView> {
if self.selected_run.is_some() {
self.snapshot_view.as_ref()
} else {
self.view.as_ref()
}
}
fn current_view_mut(&mut self) -> Option<&mut PlanDagView> {
if self.selected_run.is_some() {
self.snapshot_view.as_mut()
} else {
self.view.as_mut()
}
}
/// **The current bottleneck** `(id, title)` — CONSUMED from the shown plan-DAG
/// view's `state_json` (`facett_plandag`'s `top_bottleneck` + the node titles),
/// never recomputed here (L5). `None` when there is no constrained node (empty /
/// unavailable DAG). The pane renders it as a *"clear next · <title>"* hint.
fn bottleneck_hint(&self) -> Option<(String, String)> {
let st = Facet::state_json(self.current_view()?);
let top = st.get("top_bottleneck")?.as_str()?.to_string();
let title = st
.get("nodes")
.and_then(|n| n.as_array())
.and_then(|arr| {
arr.iter()
.find(|n| n.get("id").and_then(|i| i.as_str()) == Some(top.as_str()))
.and_then(|n| n.get("title").and_then(|t| t.as_str()))
.map(|s| s.to_string())
})
.unwrap_or_else(|| top.clone());
Some((top, title))
}
/// **Bind the pane to a (newly) selected warehouse** and re-fetch. This is the
/// mirror of nornir's per-tab `set_workspace` (viz `switch_workspace` calls it on
/// every tab): the host's warehouse selector routes the selected name here, the
/// funnel re-scopes to it, and the next fetch loads THAT warehouse's funnel. An
/// empty name is the legitimate *no selection* resting state (→ [`Conn::NoWarehouse`]),
/// never an error.
pub fn set_workspace(&mut self, workspace: impl Into<String>) {
self.workspace = workspace.into();
self.selected_run = None;
self.snapshot_view = None;
// In embedded mode, re-scope the in-process funnel to the newly-selected
// workspace (no network) — the fat-mode mirror of the remote re-fetch.
if self.embedded.is_some() {
self.embedded = Some(EmbeddedFunnel::new(self.workspace.clone()));
}
self.refresh();
}
/// Set the bearer token used for the `Authorization` header and re-fetch (the host
/// may resolve the token after construction — e.g. from `~/.nornir/token`).
pub fn set_token(&mut self, token: impl Into<String>) {
self.token = token.into();
self.refresh();
}
/// The currently-bound warehouse/workspace name (`""` = none selected).
pub fn workspace(&self) -> &str {
&self.workspace
}
/// **Build the Plan-DAG from `funnel` and record it** — the ONE place the pane
/// turns a funnel into a render, whether that funnel came from the in-process
/// engine or off the wire. Split out of [`Self::refresh`] so the off-thread live
/// consult lands through the IDENTICAL path the synchronous embedded one takes
/// (LAW 5: one writer, never a second lowering that can drift).
fn land_funnel(&mut self, funnel: funnel_core::state::Funnel) {
let dag = PlanDag::from_funnel(&funnel, None);
self.node_count = dag.node_count();
self.edge_count = dag.edge_count();
let outcome = run_outcome_from_dag(&dag);
// The graph renders are the SAME dag — lowered before the dag is consumed.
self.graph.set_dag(&dag);
self.graph3d.set_dag(&dag);
let view = view_from_dag(&dag);
let model = view.model().clone();
self.view = Some(view);
self.conn = Conn::Loaded;
self.error = None;
self.history.record(self.node_count, self.edge_count);
self.record_run(model, outcome, self.now_s);
}
/// **Start the live consult on a worker thread.** The worker owns a clone of the
/// `Arc`d source and does the gRPC dial + `Funnel.Show`; the frame keeps painting
/// and [`Self::poll_live`] lands the result.
///
/// A consult already in flight is NOT restarted — an auto-poll tick that arrives
/// while the previous one is still dialing is not a second round trip (the same
/// rule the Coverage / Data view / Coderoom / Lenses panes hold).
///
/// A blank workspace short-circuits to [`Conn::NoWarehouse`] WITHOUT dialing:
/// nothing is selected to fetch, so it is not a server problem.
fn start_live_consult(&mut self) {
if self.live_pending.is_some() {
return;
}
let Some(src) = &self.live else { return };
if self.workspace.trim().is_empty() {
// Nothing SELECTED to fetch live — the thin pane's NoWarehouse rule,
// mirrored (not a server problem; no dial, and no worker).
self.live_conn = Some(Conn::NoWarehouse);
self.live_error = None;
return;
}
let src = std::sync::Arc::clone(src);
let workspace = self.workspace.clone();
let (tx, rx) = std::sync::mpsc::channel();
// ROOT LAW #0: a NAMED worker. A spawn failure must not leave the pane
// believing a consult is in flight that nothing will ever answer — the
// `live_pending` slot is only filled once the thread actually started.
match std::thread::Builder::new()
.name("funnel-live-consult".into())
.spawn(move || {
let _ = tx.send(src.fetch(&workspace));
}) {
Ok(_) => self.live_pending = Some((rx, std::time::Instant::now())),
Err(e) => {
self.live_conn = Some(Conn::Unreachable);
self.live_error = Some(format!("could not start the funnel consult worker: {e}"));
}
}
}
/// **Non-blocking: land an in-flight live consult if the worker answered.** Called
/// once per frame from [`Panel::ui`], so the window never waits on the wire.
///
/// Returns `true` when a consult landed this call (the caller may want a repaint).
/// A consult still dialing leaves every rendered field alone — offline-first: the
/// embedded content stays on screen with `live_status` reporting the leg's state.
pub fn poll_live(&mut self) -> bool {
let Some((rx, _)) = &self.live_pending else {
return false;
};
match rx.try_recv() {
Ok(res) => {
self.live_pending = None;
self.land_live(res);
true
}
Err(std::sync::mpsc::TryRecvError::Empty) => false,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.live_pending = None;
// A dead worker is a FAILED consult, which by this pane's own contract
// KEEPS the embedded content and names the outage. Blanking a rendered
// funnel because a thread died would be the fabrication the pane exists
// to prevent.
self.live_conn = Some(Conn::Unreachable);
self.live_error =
Some("the funnel consult worker died before it answered".to_string());
true
}
}
}
/// **Blocking: wait for the in-flight live consult.** The SCRIPTED seam — the robot
/// harness (`update_json`) and a submit's re-fetch use it so a driven refresh is
/// complete when the call returns. The egui frame never calls this; it calls
/// [`Self::poll_live`].
pub fn wait_live(&mut self) {
let Some((rx, _)) = self.live_pending.take() else {
return;
};
match rx.recv() {
Ok(res) => self.land_live(res),
Err(_) => {
self.live_conn = Some(Conn::Unreachable);
self.live_error =
Some("the funnel consult worker died before it answered".to_string());
}
}
}
/// Land a consult's outcome: classify it with the SAME [`classify_conn`] rules the
/// synchronous path used (the error travels whole, so nothing is re-worded on the
/// way off the worker), and rebuild the DAG from whichever funnel now applies.
fn land_live(&mut self, res: Result<funnel_core::state::Funnel>) {
match res {
Ok(f) => {
self.live_conn = Some(Conn::Loaded);
self.live_error = None;
self.land_funnel(f);
}
Err(e) => {
self.live_conn = Some(classify_conn(&e));
self.live_error = Some(format!("{e:#}"));
// The outage KEEPS the in-process state on screen (offline-first) and
// re-lands it, so the render matches what `funnel_source` claims.
if let Some(emb) = &self.embedded {
let f = emb.funnel();
self.land_funnel(f);
}
}
}
}
/// Whether a live consult is in flight (the robot seam + `state_json`).
pub fn is_live_pending(&self) -> bool {
self.live_pending.is_some()
}
/// **Refresh and WAIT for the live leg** — the synchronous contract the scripted
/// paths want: when this returns, the consult (if any) has landed. `ui()` must
/// never call it; it calls [`Self::refresh`] + [`Self::poll_live`].
pub fn refresh_blocking(&mut self) {
self.refresh();
self.wait_live();
}
/// (Re-)fetch the funnel and rebuild the Plan-DAG. On error, drops into the clean,
/// DISTINCT failure state ([`classify_conn`]) — never the old catch-all "offline".
/// A blank workspace short-circuits to [`Conn::NoWarehouse`] WITHOUT touching the
/// network: nothing is selected to fetch, so it is not a server problem.
pub fn refresh(&mut self) {
// FAT / EMBEDDED (default): build the DAG from the in-process funnel engine
// over the local workspace — NO network, so `nornir-server` can be DOWN and the
// pane still renders + polls a live DAG. Never the "no remote nornir" error.
if self.embedded.is_some() {
// EMBEDDED + LIVE (the host-injected read leg): START the consult OFF the
// caller's thread. A healthy consult renders the SERVER's funnel — the
// `funnel_events`-backed store an MCP `funnel_submit_idea` writes into —
// through the very same DAG path `land_funnel` runs. A failed consult keeps
// the in-process state and NAMES the outage (`live_conn` → `live_status`),
// with the same distinct vocabulary as the thin pane. Never fabricated.
//
// It used to be consulted RIGHT HERE, synchronously, and `Panel::ui` calls
// `refresh()` on every auto-poll tick — so the gRPC round trip ran on the
// egui thread and the window could not paint until the server answered.
if self.live.is_some() {
self.start_live_consult();
// Nothing has ever been rendered (the pane's very first refresh): show
// the embedded funnel NOW rather than a blank frame while the consult
// is in flight. Offline-first, exactly as before — the live answer
// replaces it when it lands. `history`/`runs` both collapse consecutive
// duplicates, so this costs no spurious trail entry.
if self.view.is_none() {
let f = self
.embedded
.as_ref()
.expect("embedded checked above")
.funnel();
self.land_funnel(f);
}
return;
}
let f = self
.embedded
.as_ref()
.expect("embedded checked above")
.funnel();
self.land_funnel(f);
return;
}
// THIN / REMOTE: only reached when an explicit endpoint was configured.
if self.workspace.trim().is_empty() {
self.view = None;
self.node_count = 0;
self.edge_count = 0;
self.conn = Conn::NoWarehouse;
self.error = None;
return;
}
match fetch_funnel(&self.endpoint, &self.token, &self.workspace) {
Ok(funnel) => {
let dag = PlanDag::from_funnel(&funnel, None);
self.node_count = dag.node_count();
self.edge_count = dag.edge_count();
let outcome = run_outcome_from_dag(&dag);
// The graph renders are the SAME dag — lowered before the dag is consumed.
self.graph.set_dag(&dag);
self.graph3d.set_dag(&dag);
let view = view_from_dag(&dag);
// Capture this poll as a run snapshot (de-duped): the model is the DAG
// itself, so a selected past run re-renders through the same view.
let model = view.model().clone();
self.view = Some(view);
self.conn = Conn::Loaded;
self.error = None;
// Grow the history trail. A transient offline blip (the Err arm) never
// records, so it can't wipe the growth trajectory — only real loads add.
self.history.record(self.node_count, self.edge_count);
self.record_run(model, outcome, self.now_s);
}
Err(e) => {
self.view = None;
self.node_count = 0;
self.edge_count = 0;
self.conn = classify_conn(&e);
self.error = Some(format!("{e:#}"));
}
}
}
fn conn_key(&self) -> &'static str {
let embedded = self.embedded.is_some();
let live = self.live_loaded();
match self.conn {
Conn::Idle => "idle",
Conn::Loaded if self.node_count == 0 => {
// Provenance in the status itself: live-from-server vs in-process.
if live {
"live_empty"
} else if embedded {
"embedded_empty"
} else {
"empty"
}
}
Conn::Loaded => {
if live {
"live"
} else if embedded {
"embedded"
} else {
"loaded"
}
}
ref c => conn_token(c),
}
}
/// `state_json.live_status` — the injected live leg's own state, DISTINCT from the
/// content `status`: `none` (no source injected), `pending` (injected, not yet
/// consulted — lazy), `live` (the rendered funnel IS the server's), or one of the
/// thin pane's named failure tokens (the outage, while embedded content is shown).
fn live_key(&self) -> &'static str {
match (&self.live, &self.live_conn) {
(None, _) => "none",
(Some(_), None) => "pending",
(Some(_), Some(Conn::Loaded)) => "live",
(Some(_), Some(c)) => conn_token(c),
}
}
/// **Provenance of the rendered funnel content** (`state_json.funnel_source`) —
/// the Warehouse Navigator's `skeleton_source` vocabulary: `live: …` when the
/// server's funnel is on screen, `embedded (…)` when the in-process one is (with
/// the live outage NAMED inside the parenthesis when a source was injected).
fn funnel_source(&self) -> String {
if self.embedded.is_none() {
return format!("remote: {}", self.endpoint);
}
match (&self.live, &self.live_conn) {
(Some(src), Some(Conn::Loaded)) => format!("live: {}", src.describe()),
(Some(src), Some(c)) => {
format!("embedded (live {} — {})", conn_token(c), src.describe())
}
(Some(src), None) => format!("embedded (live pending — {})", src.describe()),
(None, _) => "embedded (local · no server)".to_string(),
}
}
}
/// The stable token for a [`Conn`] arm — ONE vocabulary shared by the pane's content
/// `status` and the injected live leg's `live_status` (the funnel-offline mislabel
/// fix's five distinct failure names, not a second set).
fn conn_token(c: &Conn) -> &'static str {
match c {
Conn::Idle => "idle",
Conn::Loaded => "loaded",
Conn::NoWarehouse => "no_warehouse",
Conn::Unreachable => "unreachable",
Conn::AuthFailed => "auth_failed",
Conn::NotServed => "not_served",
Conn::ServerError => "server_error",
}
}
impl Facet for FunnelPanel {
fn title(&self) -> &str {
"Funnel"
}
fn ui(&mut self, ui: &mut egui::Ui) {
// LIVE auto-poll: re-fetch on the interval so the DAG grows on its own.
let now_s = ui.input(|i| i.time);
self.now_s = now_s; // stamp any run captured by the poll below
// Land a consult that finished since the last frame. NON-blocking: this is the
// egui thread, and the wire is not allowed to hold it.
if self.poll_live() {
ui.ctx().request_repaint();
}
if self.poll_due(now_s) {
// STARTS the consult (see `refresh`); it does not wait for it.
self.refresh();
self.last_poll_s = now_s;
}
if self.auto_poll {
// Keep the egui loop ticking even with no user input, so the next
// poll fires. Throttled to the poll interval — never a busy-spin.
ui.ctx()
.request_repaint_after(std::time::Duration::from_secs_f32(
self.poll_interval_s as f32,
));
}
let mut do_refresh = false;
ui.horizontal(|ui| {
if ui.button("⟳ Refresh").on_hover_text(format!("re-fetch the funnel from {}", self.endpoint)).clicked() {
do_refresh = true;
}
// Live / paused toggle for the auto-poll.
let (glyph, hint) = if self.auto_poll {
("● live", "auto-polling — click to pause")
} else {
("⏸ paused", "auto-poll paused — click to resume live")
};
if ui.button(glyph).on_hover_text(hint).clicked() {
self.auto_poll = !self.auto_poll;
self.last_poll_s = now_s;
}
ui.separator();
match self.conn {
Conn::Idle => {
ui.weak("connecting…");
}
Conn::Loaded if self.node_count == 0 => {
if self.embedded.is_some() {
ui.weak("embedded funnel is empty — add an idea to grow it (local · no server)");
} else {
ui.weak("funnel is empty (no ideas/plans yet)");
}
}
Conn::Loaded => {
// PLAIN NUMBERS (no thousands separators) — a nordisk law.
let live = self.live_loaded();
let lead = if live {
"live funnel (via host) · "
} else if self.embedded.is_some() {
"embedded funnel · "
} else {
"live funnel · "
};
ui.label(format!("{lead}{} nodes / {} edges", self.node_count, self.edge_count))
.on_hover_text(if live {
"the server's real funnel (funnel_events-backed), pulled through the host-injected live source"
} else if self.embedded.is_some() {
"in-process funnel engine over the local workspace — no nornir-server needed (fat mode)"
} else {
"live funnel from nornir-server (thin/remote)"
});
}
// ── DISTINCT failure states (the funnel-offline mislabel fix) ──
// Amber = "you/selection" problem (not the server); red = a real
// server/transport fault. NEVER "offline" when the server is up.
Conn::NoWarehouse => {
ui.colored_label(
egui::Color32::from_rgb(224, 170, 90),
"no warehouse selected — pick one to load its funnel",
)
.on_hover_text("the funnel belongs to a warehouse; select one in the warehouse picker");
}
Conn::NotServed => {
ui.colored_label(
egui::Color32::from_rgb(224, 170, 90),
format!("warehouse '{}' not served by nornir-server", self.workspace),
)
.on_hover_text(format!(
"nornir-server at {} is up but does not serve '{}' — register/populate it (nornir workspace add)",
self.endpoint, self.workspace
));
}
Conn::AuthFailed => {
let resp = ui.colored_label(
egui::Color32::from_rgb(224, 120, 90),
"authentication failed — check the nornir-server token",
);
if let Some(e) = &self.error {
resp.on_hover_text(format!("set $NORNIR_SERVER_TOKEN (or ~/.nornir/token): {e}"));
}
}
Conn::Unreachable => {
let resp = ui.colored_label(
egui::Color32::from_rgb(224, 120, 90),
format!("nornir-server unreachable at {}", self.endpoint),
);
if let Some(e) = &self.error {
resp.on_hover_text(e);
}
}
Conn::ServerError => {
let resp = ui.colored_label(
egui::Color32::from_rgb(224, 120, 90),
"funnel error (nornir-server returned an error)",
);
if let Some(e) = &self.error {
resp.on_hover_text(e);
}
}
}
});
if do_refresh {
self.refresh();
self.last_poll_s = now_s;
}
// ── the injected live leg's OUTAGE, NAMED (never silently absorbed) ──
// The embedded content is still shown (offline-first), but the pane says so:
// the host wired a live source and it could not be honoured this poll.
if self.embedded.is_some() {
if let (Some(_), Some(c)) = (&self.live, &self.live_conn) {
if *c != Conn::Loaded {
let msg = match c {
Conn::NoWarehouse => {
"live funnel: no warehouse selected — showing the embedded funnel"
.to_string()
}
c => format!(
"live funnel {} — showing the embedded funnel",
conn_token(c)
),
};
let resp = ui.colored_label(egui::Color32::from_rgb(224, 170, 90), msg);
if let Some(e) = &self.live_error {
resp.on_hover_text(e.as_str());
}
}
}
}
// ── the submit strip (WRITE leg): type an idea/error → grow the DAG ──
let mut do_submit = false;
ui.horizontal(|ui| {
// idea | error kind toggle.
egui::ComboBox::from_id_salt("funnel-intake-kind")
.selected_text(match self.intake_kind {
IntakeKind::Idea => "💡 idea",
IntakeKind::Error => "🐛 error",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.intake_kind, IntakeKind::Idea, "💡 idea");
ui.selectable_value(&mut self.intake_kind, IntakeKind::Error, "🐛 error");
});
let hint = match self.intake_kind {
IntakeKind::Idea => "describe an idea to plan…",
IntakeKind::Error => "describe an error to triage…",
};
let resp = ui.add(
egui::TextEdit::singleline(&mut self.draft)
.hint_text(hint)
.desired_width(ui.available_width() - 90.0),
);
// Enter in the field submits (and keeps focus flow natural).
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
do_submit = true;
}
let enabled = !self.draft.trim().is_empty();
if ui
.add_enabled(enabled, egui::Button::new("+ Submit"))
.on_hover_text(format!(
"submit to nornir-server at {} (grows the plan-DAG)",
self.endpoint
))
.clicked()
{
do_submit = true;
}
});
// Last-submit feedback line.
match &self.last_submit {
Some(Ok(id)) => {
ui.colored_label(
egui::Color32::from_rgb(120, 190, 120),
format!("submitted · {id}"),
);
}
Some(Err(e)) => {
ui.colored_label(
egui::Color32::from_rgb(224, 120, 90),
"submit failed (server offline?)",
)
.on_hover_text(e.as_str());
}
None => {}
}
if do_submit {
self.submit_draft();
self.last_poll_s = now_s;
}
// ── the growth trail ("addera historik"): draw HOW the DAG grew over polls ──
if !self.history.is_empty() {
ui.horizontal(|ui| {
let growth = self.history.node_growth();
let sign = if growth >= 0 { "+" } else { "" };
ui.weak(format!(
"history · {} samples · peak {} nodes · {sign}{growth} grown",
self.history.len(),
self.history.peak_nodes(),
));
// The tail of the trail as node counts joined by → (last 12 for width).
let samples = self.history.samples();
let tail = &samples[samples.len().saturating_sub(12)..];
let trail = tail
.iter()
.map(|s| s.node_count.to_string())
.collect::<Vec<_>>()
.join(" → ");
ui.weak(trail)
.on_hover_text("funnel DAG node-count over successive polls (oldest → newest)");
if ui
.small_button("clear")
.on_hover_text("clear the growth history")
.clicked()
{
self.history.clear();
}
});
}
// ── EVENT-SOURCED run history: what actually RAN, from funnel-core's ledger ─
//
// Distinct from the run PICKER below (which snapshots the rendered DAG per
// poll): these are the funnel's own `RunRecorded` events, folded by
// funnel-core, so the strip shows real executions — who ran what, and how it
// came out — rather than view states.
if let Some(rows) = self.event_runs().as_array().filter(|r| !r.is_empty()) {
ui.horizontal_wrapped(|ui| {
ui.weak(format!("runs · {} recorded", rows.len()));
// Newest first (the projection's order); cap the strip's width.
for row in rows.iter().take(6) {
let outcome = row["outcome"].as_str().unwrap_or("?");
let (glyph, colour) = match outcome {
"ok" => ("✔", egui::Color32::from_rgb(0x4c, 0xaf, 0x50)),
"failed" => ("✖", egui::Color32::from_rgb(0xd9, 0x53, 0x4f)),
_ => ("⊘", egui::Color32::from_rgb(0xe0, 0xa8, 0x00)),
};
let node = row["node_id"].as_str().unwrap_or("?");
let run = row["run_id"].as_str().unwrap_or("?");
ui.colored_label(colour, format!("{glyph} {run}"))
.on_hover_text(format!(
"{run} on {node} — {outcome}, by {}",
row["ran_by"].as_str().unwrap_or("?")
));
}
if rows.len() > 6 {
ui.weak(format!("+{} older", rows.len() - 6));
}
});
}
// ── the Claude credential the funnel expands ideas with ──────────────────
//
// The funnel drives Claude, so a missing credential is the pane's problem to
// surface — silently producing no plan is the failure mode this prevents.
// A SOURCE LABEL only; the key never enters this crate.
if self.claude_source == "none" {
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::from_rgb(0xe0, 0xa8, 0x00), "○");
ui.weak("no Claude credential — configure it in Settings to expand ideas");
});
}
// ── run history ("addera historik"): pick a PAST run to view its final DAG ──
if !self.runs.is_empty() {
let mut pick: Option<Option<usize>> = None;
ui.horizontal(|ui| {
let selected_text = match self.selected_run {
None => "◷ latest (live)".to_string(),
Some(seq) => match self.runs.get(seq) {
Some(r) => format!(
"run #{seq} · {} nodes · {}",
r.node_count,
r.outcome.as_str()
),
None => "◷ latest (live)".to_string(),
},
};
egui::ComboBox::from_id_salt("funnel-run-picker")
.selected_text(selected_text)
.show_ui(ui, |ui| {
if ui
.selectable_label(self.selected_run.is_none(), "◷ latest (live)")
.clicked()
{
pick = Some(None);
}
// Newest run first — the most recent history entry on top.
for r in self.runs.runs().iter().rev() {
let label = format!(
"run #{} · {} nodes / {} edges · {}",
r.seq,
r.node_count,
r.edge_count,
r.outcome.as_str()
);
if ui
.selectable_label(self.selected_run == Some(r.seq), label)
.clicked()
{
pick = Some(Some(r.seq));
}
}
});
ui.weak(format!("history · {} runs", self.runs.len()));
if self.selected_run.is_some()
&& ui
.small_button("● live")
.on_hover_text("return to the live/latest view")
.clicked()
{
pick = Some(None);
}
});
if let Some(sel) = pick {
self.select_run(sel);
}
// The selected past run's summary (started / node count / outcome).
if let Some(seq) = self.selected_run {
if let Some(r) = self.runs.get(seq) {
ui.weak(format!(
"viewing run #{seq} · {} nodes / {} edges · {}",
r.node_count,
r.edge_count,
r.outcome.as_str()
));
}
}
}
// ── bottleneck annotation: WHERE the funnel is constrained (consumed) ──
if let Some((_, title)) = self.bottleneck_hint() {
ui.horizontal(|ui| {
ui.colored_label(egui::Color32::from_rgb(224, 170, 90), format!("clear next · {title}"))
.on_hover_text("the top bottleneck (unblocks the most downstream work) — from facett-plandag's constraint analysis");
});
}
// ── which render of the DAG: card lane or node-link graph ────────────────
// Two renders of ONE dag. Only offered when there IS a dag to render, and the
// graph only for the LIVE view (a historical run snapshots the card view).
if self.current_view().is_some() {
ui.horizontal(|ui| {
ui.label("View:");
if ui.selectable_label(self.dag_view == FunnelDagView::Cards, "Cards").clicked() {
self.dag_view = FunnelDagView::Cards;
}
let can_graph = self.selected_run.is_none();
if ui
.add_enabled(can_graph, egui::Button::selectable(self.dag_view == FunnelDagView::Graph, "Graph"))
.on_disabled_hover_text("the graph render follows the LIVE funnel — return to latest to see it")
.clicked()
{
self.dag_view = FunnelDagView::Graph;
}
// The 3D layered render (live view only) — a camera-only 3D DAG.
if ui
.add_enabled(can_graph, egui::Button::selectable(self.dag_view == FunnelDagView::Graph3d, "3D Graph"))
.on_hover_text("the funnel Plan-DAG as a 3D layered graph (ranks stacked in space) — reuses the world graph's orthographic 3D camera")
.on_disabled_hover_text("the 3D graph follows the LIVE funnel — return to latest to see it")
.clicked()
{
self.dag_view = FunnelDagView::Graph3d;
}
if self.dag_view == FunnelDagView::Graph && can_graph {
ui.separator();
// The measurement, on screen: nodes drawn and the last frame cost,
// and that there is NO node cap (nornir's CallGraph caps at 160).
let us = self.graph.last_frame_us().unwrap_or(0);
ui.weak(format!(
"{} node(s) · {} edge(s) · {:.1} ms/frame · no node cap",
self.graph.node_count(),
self.graph.edge_count(),
us as f64 / 1000.0,
));
}
if self.dag_view == FunnelDagView::Graph3d && can_graph {
ui.separator();
// The "3D rotating" toggle — spins the CAMERA, never the layout.
let mut rot = self.graph3d.is_rotating();
if ui
.checkbox(&mut rot, "3D rotating")
.on_hover_text("auto-spin the camera about the vertical axis (projection-only — the layout stays cached). On by default.")
.changed()
{
self.graph3d.set_rotate(rot);
}
// The idea WORDS beside each node — on by default (the funnel is small;
// only the world graph's universe scale turns labels off).
let mut lab = self.graph3d.labels_on();
if ui
.checkbox(&mut lab, "labels")
.on_hover_text("each node's idea words, attached in 3D (depth-cued). On by default — funnel data is small.")
.changed()
{
self.graph3d.set_labels(lab);
}
let us = self.graph3d.last_frame_us().unwrap_or(0);
ui.weak(format!(
"{} node(s) · {} edge(s) · {:.1} ms/frame · no node cap · click a node to drill",
self.graph3d.node_count(),
self.graph3d.edge_count(),
us as f64 / 1000.0,
));
}
});
}
ui.separator();
// The GRAPH render (live view only) — the shared facett-graphview engine.
if self.dag_view == FunnelDagView::Graph
&& self.selected_run.is_none()
&& self.view.is_some()
{
self.graph.ui(ui);
return;
}
// The 3D layered GRAPH render (live view only) — camera-only 3D + rotate. A
// node click drills to the card detail: the id is forwarded to the SAME
// `facett-plandag` view (its selection), so switching to Cards shows that card's
// detail line — one DAG, one selection, reused across renders.
if self.dag_view == FunnelDagView::Graph3d
&& self.selected_run.is_none()
&& self.view.is_some()
{
if let Some(id) = self.graph3d.ui(ui) {
if let Some(v) = self.view.as_mut() {
// The card view keys resolved nodes as `node:<id>` (see plandag's
// `resolve`), so the raw funnel id is prefixed to hit the drill.
v.update(facett_plandag::Msg::Select(format!("node:{id}")));
}
}
return;
}
let endpoint = self.endpoint.clone();
let conn = self.conn.clone();
match self.current_view_mut() {
Some(v) => Facet::ui(v, ui),
None => {
let workspace = self.workspace.clone();
ui.centered_and_justified(|ui| match conn {
Conn::NoWarehouse => {
ui.weak("select a warehouse to load its funnel");
}
Conn::NotServed => {
ui.weak(format!("warehouse '{workspace}' is not served by nornir-server at {endpoint}"));
}
Conn::AuthFailed => {
ui.weak("authentication failed — set the nornir-server token, then press ⟳ Refresh");
}
Conn::Unreachable => {
ui.weak(format!("could not reach nornir-server at {endpoint} — press ⟳ Refresh"));
}
Conn::ServerError => {
ui.weak(format!("nornir-server at {endpoint} returned an error — press ⟳ Refresh"));
}
_ => {
ui.weak("no funnel to render");
}
});
}
}
}
fn state_json(&self) -> serde_json::Value {
// The DAG currently shown: the selected past-run snapshot, else the live view.
let plandag = match self.current_view() {
Some(v) => Facet::state_json(v),
None => serde_json::json!({ "node_count": 0, "edge_count": 0, "nodes": [] }),
};
// Bottleneck annotation, CONSUMED from that view's constraint analysis.
let (bottleneck, bottleneck_title) = match self.bottleneck_hint() {
Some((id, title)) => (Some(id), Some(title)),
None => (None, None),
};
let critical_path = plandag
.get("critical_path")
.cloned()
.unwrap_or(serde_json::Value::Null);
let (submit_status, submit_id, submit_error) = match &self.last_submit {
Some(Ok(id)) => ("ok", Some(id.clone()), None),
Some(Err(e)) => ("error", None, Some(e.clone())),
None => ("none", None, None),
};
serde_json::json!({
"kind": "funnel",
// FAT/embedded (in-process, no server) vs thin/remote (gRPC to nornir-server).
"mode": if self.embedded.is_some() { "embedded" } else { "remote" },
// PROVENANCE of the rendered content — `live: …` (the server's real
// funnel via the host-injected source) vs `embedded (…)` (in-process,
// with any live outage NAMED). The warehouse-nav `skeleton_source`
// vocabulary, applied to the funnel.
"funnel_source": self.funnel_source(),
// The injected live leg's own state: none | pending | live | one of the
// five distinct failure tokens (the outage, while embedded is shown).
"live_status": self.live_key(),
"live_error": self.live_error,
"endpoint": self.endpoint,
"workspace": self.workspace,
"status": self.conn_key(),
"node_count": self.node_count,
"edge_count": self.edge_count,
"error": self.error,
"auto_poll": self.auto_poll,
"poll_interval_s": self.poll_interval_s,
"draft": self.draft,
"intake_kind": match self.intake_kind { IntakeKind::Idea => "idea", IntakeKind::Error => "error" },
"submit_status": submit_status,
"submit_id": submit_id,
"submit_error": submit_error,
"history": self.history.to_json(),
"history_len": self.history.len(),
"history_peak_nodes": self.history.peak_nodes(),
"node_growth": self.history.node_growth(),
// RUN HISTORY (the run dimension of "addera historik"): past runs + which is shown.
"runs": self.runs.to_json(),
"run_count": self.runs.len(),
"selected_run": self.selected_run,
// EVENT-SOURCED run history — funnel-core's OWN `RunRecorded` ledger,
// projected by `Funnel::runs_to_json` (newest first). Embedded mode only:
// the remote `Funnel.Show` RPC returns the current funnel with no run
// ledger on the wire, so a thin pane has no events to fold. Empty (not
// absent) there, so the seam shape is the same in both modes.
"event_runs": self.event_runs(),
"event_run_count": self.event_run_count(),
// The Claude credential SOURCE, as reported by the host (never a value):
// the funnel drives Claude to expand ideas, so the pane shows whether a
// credential is configured and points at Settings when it is not.
"claude_source": self.claude_source,
"claude_configured": self.claude_source != "none",
// BOTTLENECK annotation (consumed from facett-plandag's constraint analysis).
"bottleneck": bottleneck,
"bottleneck_title": bottleneck_title,
"critical_path": critical_path,
"plandag": plandag,
// Which render of the DAG is on screen, and what the GRAPH render drew:
// node/edge counts, the measured frame cost, and node_cap:null (no
// MAX_NODES ceiling, unlike nornir's CallGraph pane).
"dag_view": self.dag_view.key(),
"graph": self.graph.report(),
// The 3D layered render: whether it's the active view, node/edge counts, the
// live camera azimuth (rotate_angle), the rotate toggle (spin — ON by
// default), the labels toggle + the exact label TEXTS painted (the idea
// words attached in 3D), the lane histogram, and the drilled selection —
// all AS DATA so a robot asserts the 3D render.
"graph3d_on": self.dag_view == FunnelDagView::Graph3d,
"graph3d": self.graph3d.report(),
})
}
fn update_json(&mut self, msg_json: &str) {
// The pane's own control surface (drives it headless from a robot test):
// {"set_workspace": "…"} → bind to a selected warehouse + refetch
// {"set_token": "…"} → set the bearer token + refetch
// {"refresh": true} / {"poll": true} → force a re-fetch
// {"auto_poll": bool} → toggle live polling
// {"draft": "…"} → set the submit-strip text
// {"intake_kind": "idea"|"error"} → set the intake kind
// {"submit": true} → submit the current draft (WRITE)
// {"clear_history": true} → clear the growth-history trail
// {"select_run": <seq>|null} → view a past run (null ⇒ live/latest)
// {"clear_runs": true} → drop the run history + back to live
// {"dag_view": "cards"|"graph"|"graph3d"} → switch the DAG render
// {"graph3d_rotate": bool} → auto-spin the 3D camera (projection-only)
// {"graph3d_labels": bool} → the idea words beside each 3D node
// {"graph3d_select": "<node-id>"} → drill: select a 3D node → card detail
// {"claude_source": "…"} → the host's resolved Claude credential
// SOURCE label (never a key value)
// {"submit_text": "…", ["submit_kind": "idea"|"error"]}
// → set draft(+kind) then submit
// Anything else is forwarded to the inner Plan-DAG facet (its own JSON seam).
if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) {
// Warehouse selection: the host's selector routes the chosen name here so
// the funnel re-binds to the SELECTED warehouse (nornir's per-tab
// set_workspace, mirrored). An empty string is the valid "none" state.
if let Some(ws) = v.get("set_workspace").and_then(|r| r.as_str()) {
self.set_workspace(ws);
return;
}
// The host's resolved Claude credential SOURCE (a label, never a key).
if let Some(src) = v.get("claude_source").and_then(|r| r.as_str()) {
self.set_claude_source(src);
return;
}
// Switch the DAG render: card lane ↔ flat graph ↔ 3D layered graph.
if let Some(dv) = v
.get("dag_view")
.and_then(|r| r.as_str())
.and_then(FunnelDagView::parse)
{
self.dag_view = dv;
return;
}
// Toggle the 3D graph's camera auto-spin (projection-only; layout stays cached).
if let Some(on) = v.get("graph3d_rotate").and_then(|r| r.as_bool()) {
self.graph3d.set_rotate(on);
return;
}
// Toggle the 3D graph's per-node labels (the idea words attached in 3D).
if let Some(on) = v.get("graph3d_labels").and_then(|r| r.as_bool()) {
self.graph3d.set_labels(on);
return;
}
// Drill: select a 3D node by id (the headless twin of a node click) and
// forward it to the SAME card view's selection, so switching to Cards shows
// that card's detail — the drill reuses the existing card detail.
if let Some(id) = v.get("graph3d_select").and_then(|r| r.as_str()) {
if let Some(sel) = self.graph3d.select_id(id) {
if let Some(vw) = self.view.as_mut() {
// Prefixed to the card view's `node:<id>` resolved key (the drill).
vw.update(facett_plandag::Msg::Select(format!("node:{sel}")));
}
}
return;
}
if let Some(tok) = v.get("set_token").and_then(|r| r.as_str()) {
self.set_token(tok);
return;
}
if let Some(on) = v.get("auto_poll").and_then(|r| r.as_bool()) {
self.auto_poll = on;
return;
}
if let Some(k) = v.get("intake_kind").and_then(|r| r.as_str()) {
self.intake_kind = if k.eq_ignore_ascii_case("error") {
IntakeKind::Error
} else {
IntakeKind::Idea
};
// fall through — a message may also set the draft / submit.
}
if let Some(t) = v.get("draft").and_then(|r| r.as_str()) {
self.draft = t.to_string();
}
if let Some(t) = v.get("submit_text").and_then(|r| r.as_str()) {
self.draft = t.to_string();
if let Some(k) = v.get("submit_kind").and_then(|r| r.as_str()) {
self.intake_kind = if k.eq_ignore_ascii_case("error") {
IntakeKind::Error
} else {
IntakeKind::Idea
};
}
self.submit_draft();
return;
}
if v.get("submit").and_then(|r| r.as_bool()) == Some(true) {
self.submit_draft();
return;
}
if v.get("clear_history").and_then(|r| r.as_bool()) == Some(true) {
self.history.clear();
return;
}
// Run picker: {"select_run": <seq>} views a past run; {"select_run": null}
// (or false) returns to the live/latest view.
if let Some(sr) = v.get("select_run") {
if sr.is_null() || sr.as_bool() == Some(false) {
self.select_run(None);
} else if let Some(seq) = sr.as_u64() {
self.select_run(Some(seq as usize));
}
return;
}
if v.get("clear_runs").and_then(|r| r.as_bool()) == Some(true) {
self.select_run(None);
self.runs.clear();
return;
}
if v.get("refresh").and_then(|r| r.as_bool()) == Some(true)
|| v.get("poll").and_then(|r| r.as_bool()) == Some(true)
{
// The SCRIPTED seam is synchronous by contract: a driven refresh is
// complete when this returns, so a robot assertion right after it sees
// the consult's result. Only the egui frame goes through the
// non-blocking `refresh` + `poll_live` pair.
self.refresh_blocking();
return;
}
// A message that only set draft/intake_kind (no submit) is handled.
if v.get("draft").is_some() || v.get("intake_kind").is_some() {
return;
}
}
// Forward anything else to the CURRENTLY-SHOWN Plan-DAG facet (the selected
// snapshot when a past run is picked, else the live view) — its own JSON seam.
if let Some(v) = self.current_view_mut() {
Facet::update_json(v, msg_json);
}
}
}
/// **Resolve the nornir-server bearer token** the way the `nornir` CLI does: prefer
/// `$NORNIR_SERVER_TOKEN`, then fall back to the user-readable `~/.nornir/token`
/// (trimmed). Empty ⇒ no auth. Exposed so a host can seed the same token the CLI uses
/// without re-implementing the ladder.
///
/// Folds onto the SHARED `nornir_service::resolve_token` env→file→open ladder (Law #5 —
/// the one helper korp/nornir/dwarves reuse), parameterised on nornir's env var + token
/// file. No `--token` flag here (a host seeds this programmatically), so the flag arm is
/// `None`.
pub fn resolve_token() -> String {
let token_file =
std::env::var_os("HOME").map(|h| std::path::Path::new(&h).join(".nornir").join("token"));
nornir_service::resolve_token(None, "NORNIR_SERVER_TOKEN", token_file.as_deref())
}
/// **The backend-(2) seam.** Build the live funnel pane as a `Box<dyn Panel>` a host
/// drops into a tab. `endpoint` is the `nornir-server` url (e.g.
/// `http://127.0.0.1:7878`). The bearer token is resolved by [`resolve_token`]
/// (`$NORNIR_SERVER_TOKEN` → `~/.nornir/token`) and the served workspace from
/// `$NORNIR_WORKSPACE` (empty ⇒ no warehouse selected — the pane shows *"no warehouse
/// selected"*, NOT "offline"). The first fetch happens eagerly; every failure yields
/// a clean, DISTINCT state (no-warehouse / unreachable / auth-failed / not-served),
/// never a panic. A host with a warehouse selector should call
/// [`FunnelPanel::set_workspace`] to bind the funnel to the selected warehouse.
pub fn panel(endpoint: impl Into<String>) -> Box<dyn Panel> {
let token = resolve_token();
let workspace = std::env::var("NORNIR_WORKSPACE").unwrap_or_default();
Box::new(FunnelPanel::new(endpoint.into(), token, workspace))
}
/// Like [`panel`] but with the token + workspace passed explicitly (for hosts that
/// manage their own credentials rather than reading the environment).
pub fn panel_with(
endpoint: impl Into<String>,
token: impl Into<String>,
workspace: impl Into<String>,
) -> Box<dyn Panel> {
Box::new(FunnelPanel::new(
endpoint.into(),
token.into(),
workspace.into(),
))
}
/// **The FAT / EMBEDDED seam (the default).** Build the funnel pane in-process over the
/// local `workspace` — **no `nornir-server`**. The pane renders + polls its own
/// Plan-DAG with no server running and grows it as ideas are submitted, all offline
/// (the fat-mode doctrine: dwarves is self-contained; nornir-server can be DOWN). A
/// host only reaches for [`panel`]/[`panel_with`] when an explicit remote endpoint is
/// configured.
pub fn panel_embedded(workspace: impl Into<String>) -> Box<dyn Panel> {
Box::new(FunnelPanel::new_embedded(workspace.into()))
}
/// [`panel_embedded`] over the SAMPLE funnel — tests only (`testkit`).
#[cfg(any(test, feature = "testkit"))]
pub fn panel_embedded_seeded(workspace: impl Into<String>) -> Box<dyn Panel> {
Box::new(FunnelPanel::new_embedded_seeded(workspace.into()))
}
/// **The EMBEDDED + LIVE seam — the shipped host wiring.** The pane is built
/// FAT/embedded exactly like [`panel_embedded`] (offline-first, immediate, nothing
/// dialed at construction), with the host's endpoint + token injected as a
/// [`GrpcFunnelSource`] that the refresh/poll path consults LAZILY: when the host
/// serves `/nornir.v1.Funnel/Show`, the tab renders the server's REAL,
/// `funnel_events`-backed funnel (so an idea submitted over the MCP write leg becomes
/// VISIBLE here) and routes the submit strip over the same wire; a dead/denied/
/// not-serving host keeps the embedded state and NAMES the outage
/// (`state_json.live_status` + `funnel_source`) — honest fallback, never fabrication.
pub fn panel_embedded_live(endpoint: &str, token: &str, workspace: &str) -> Box<dyn Panel> {
Box::new(
FunnelPanel::new_embedded(workspace)
.with_live_source(Box::new(GrpcFunnelSource::new(endpoint, token))),
)
}
/// **The reusable MOCK `Funnel` gRPC server** (feature `testkit`) — a REAL tonic
/// `nornir.v1.Funnel` service on an ephemeral port whose in-memory store `SubmitIdea`
/// grows and `Show` returns, so a host's robot tests prove submit→visible over the
/// actual wire (never a stub of this crate's own client). Mounted through the
/// warehouse testkit's ONE `spawn_router` body (L5 — no second thread/runtime/wait
/// twin).
#[cfg(feature = "testkit")]
pub mod testkit {
use std::sync::{Arc, Mutex};
use crate::pb;
/// The running-mock handle (endpoint + shutdown-on-drop) — the SAME type the
/// warehouse testkit hands out, reused rather than twinned.
pub use nornir_warehouse_embed::testkit::MockWarehouseServer as MockFunnelServer;
/// An in-memory `nornir.v1.Funnel`: `Show` returns the current store; `SubmitIdea`
/// appends a plan-less idea (`i-001`, `i-002`, …) exactly as the live server's
/// intake does before any decompose — so a submitted idea round-trips the wire and
/// comes back visible in the very next `Show`. The plan-write trio (`CreatePlan` /
/// `AddNode` / `Link`) grows plans under those ideas with the live server's
/// sequential-id + needs→deps semantics, so a host's tests prove
/// decompose→persist→visible over the actual wire.
#[derive(Clone, Default)]
pub struct MockFunnel {
ideas: Arc<Mutex<Vec<pb::FunnelDumpIdea>>>,
}
#[tonic::async_trait]
impl pb::funnel_server::Funnel for MockFunnel {
async fn show(
&self,
_req: tonic::Request<pb::Empty>,
) -> Result<tonic::Response<pb::FunnelDump>, tonic::Status> {
Ok(tonic::Response::new(pb::FunnelDump {
ideas: self.ideas.lock().unwrap().clone(),
}))
}
async fn submit_idea(
&self,
req: tonic::Request<pb::SubmitIdeaRequest>,
) -> Result<tonic::Response<pb::IdRef>, tonic::Status> {
let r = req.into_inner();
if r.text.trim().is_empty() {
// The live server rejects an empty intake; the mock must too, so a
// test cannot green on a swallowed no-op.
return Err(tonic::Status::invalid_argument("empty idea text"));
}
let mut ideas = self.ideas.lock().unwrap();
let id = format!("i-{:03}", ideas.len() + 1);
ideas.push(pb::FunnelDumpIdea {
id: id.clone(),
text: r.text,
plans: Vec::new(),
source: r.source,
item_kind: if r.item_kind.is_empty() {
"idea".into()
} else {
r.item_kind
},
});
Ok(tonic::Response::new(pb::IdRef { id }))
}
async fn create_plan(
&self,
req: tonic::Request<pb::CreatePlanRequest>,
) -> Result<tonic::Response<pb::IdRef>, tonic::Status> {
let r = req.into_inner();
let mut ideas = self.ideas.lock().unwrap();
// Live semantics: sequential plan id across the store; a plan on an
// unknown idea is REJECTED (funnel-core's apply bails), never dangled.
let id = format!(
"p-{:03}",
ideas.iter().map(|i| i.plans.len()).sum::<usize>() + 1
);
let Some(idea) = ideas.iter_mut().find(|i| i.id == r.idea_id) else {
return Err(tonic::Status::internal(format!(
"PlanCreated for unknown idea `{}`",
r.idea_id
)));
};
idea.plans.push(pb::FunnelDumpPlan {
id: id.clone(),
idea_id: r.idea_id,
summary: r.summary,
nodes: Vec::new(),
// The live server records PlanCreated + PlanStatusChanged(Active);
// Show carries the Debug repr.
status: "Active".into(),
});
Ok(tonic::Response::new(pb::IdRef { id }))
}
async fn add_node(
&self,
req: tonic::Request<pb::AddNodeRequest>,
) -> Result<tonic::Response<pb::IdRef>, tonic::Status> {
let r = req.into_inner();
let mut ideas = self.ideas.lock().unwrap();
let id = format!(
"n-{:03}",
ideas
.iter()
.flat_map(|i| &i.plans)
.map(|p| p.nodes.len())
.sum::<usize>()
+ 1
);
let Some(plan) = ideas
.iter_mut()
.flat_map(|i| &mut i.plans)
.find(|p| p.id == r.plan_id)
else {
return Err(tonic::Status::internal(format!(
"NodeAdded for unknown plan `{}`",
r.plan_id
)));
};
for need in &r.needs {
if !plan.nodes.iter().any(|n| &n.id == need) {
return Err(tonic::Status::internal(format!(
"EdgeAdded from unknown node `{need}`"
)));
}
}
plan.nodes.push(pb::FunnelDumpNode {
id: id.clone(),
kind: r.kind,
title: r.title,
status: "Pending".into(),
deps: r.needs,
});
Ok(tonic::Response::new(pb::IdRef { id }))
}
async fn link(
&self,
req: tonic::Request<pb::LinkRequest>,
) -> Result<tonic::Response<pb::Empty>, tonic::Status> {
let r = req.into_inner();
let mut ideas = self.ideas.lock().unwrap();
let Some(plan) = ideas
.iter_mut()
.flat_map(|i| &mut i.plans)
.find(|p| p.id == r.plan_id)
else {
return Err(tonic::Status::internal(format!(
"EdgeAdded for unknown plan `{}`",
r.plan_id
)));
};
if !plan.nodes.iter().any(|n| n.id == r.from) {
return Err(tonic::Status::internal(format!(
"EdgeAdded from unknown node `{}`",
r.from
)));
}
let Some(to) = plan.nodes.iter_mut().find(|n| n.id == r.to) else {
return Err(tonic::Status::internal(format!(
"EdgeAdded to unknown node `{}`",
r.to
)));
};
if !to.deps.contains(&r.from) {
to.deps.push(r.from);
}
Ok(tonic::Response::new(pb::Empty {}))
}
}
/// Spawn the mock `Funnel` server on an ephemeral port, pre-loaded with `seed`
/// ideas. Dropping the handle shuts it down.
pub fn spawn_mock_funnel(seed: Vec<pb::FunnelDumpIdea>) -> MockFunnelServer {
let mock = MockFunnel {
ideas: Arc::new(Mutex::new(seed)),
};
nornir_warehouse_embed::testkit::spawn_router(move |mut s| {
s.add_service(pb::funnel_server::FunnelServer::new(mock))
})
}
}
#[cfg(all(test, feature = "testkit"))]
mod persist_wire_tests {
//! The decompose PERSIST leg over the REAL wire: [`persist_plan`] drives
//! `SubmitIdea` + `CreatePlan` + `AddNode` + `Link` against the testkit's tonic
//! mock, and the plan's node TITLES come back out of `Funnel.Show` — applied
//! output through the socket, never a state round-trip.
use super::*;
fn spec(local: &str, kind: &str, title: &str, needs: &[&str]) -> PlanNodeSpec {
PlanNodeSpec {
local_id: local.into(),
kind: kind.into(),
title: title.into(),
needs: needs.iter().map(|s| s.to_string()).collect(),
}
}
/// RED-when-broken (proven by sabotage: dropping the `create_plan`/`add_node`
/// calls fails the title assertions): a persisted plan REAPPEARS in `Show` —
/// idea intaken, plan attached to it, both node titles present, and the edge
/// translated from local ids (`n1` → server `n-001`) into the dump's `deps`.
#[test]
fn persist_plan_reappears_in_show_with_titles_and_edges() {
let srv = testkit::spawn_mock_funnel(Vec::new());
let out = persist_plan(
&srv.endpoint,
"",
"nordisk",
None,
"add a cache to the store",
"cache plan",
&[
spec("n1", "code:write", "add LruCache to store.rs", &[]),
spec("n2", "test:run", "test cache hit/miss", &["n1"]),
],
)
.expect("persist over the wire");
assert_eq!(out.idea_id, "i-001", "the idea was intaken first");
assert_eq!(out.plan_id, "p-001");
// Applied output: the titles come back OUT of Funnel.Show over the wire.
let f = fetch_funnel(&srv.endpoint, "", "nordisk").expect("Show after persist");
let idea = f.ideas.values().next().expect("idea visible");
assert_eq!(idea.text, "add a cache to the store");
let plan = f
.plans
.values()
.find(|p| p.idea_id == idea.id)
.expect("plan attached");
assert_eq!(plan.summary, "cache plan");
let titles: Vec<&str> = plan
.nodes
.values()
.filter_map(|n| n.prompt_excerpt.as_deref())
.collect();
assert!(
titles.contains(&"add LruCache to store.rs") && titles.contains(&"test cache hit/miss"),
"both node titles round-trip Show: {titles:?}"
);
// The n1→n2 edge crossed as server ids.
assert_eq!(plan.edges.len(), 1, "the one dependency edge persisted");
// Attaching to the EXISTING idea (the id the first persist returned) must
// not intake a second idea.
let again = persist_plan(
&srv.endpoint,
"",
"nordisk",
Some(&out.idea_id),
"ignored — idea exists",
"follow-up plan",
&[spec("n1", "doc:update", "document the cache", &[])],
)
.expect("persist onto the existing idea");
assert_eq!(again.idea_id, out.idea_id);
let f = fetch_funnel(&srv.endpoint, "", "nordisk").expect("Show again");
assert_eq!(f.ideas.len(), 1, "no duplicate idea intaken");
assert_eq!(
f.plans.len(),
2,
"the second plan attached to the same idea"
);
}
/// A malformed spec is refused BEFORE the first write: dangling `needs` (and an
/// empty plan) bail with an honest error and the store stays untouched — no
/// half-persisted plan from a bad model reply.
#[test]
fn malformed_plan_is_refused_before_any_write() {
let srv = testkit::spawn_mock_funnel(Vec::new());
let err = persist_plan(
&srv.endpoint,
"",
"nordisk",
None,
"idea",
"summary",
&[spec("n1", "code:write", "something", &["n-does-not-exist"])],
)
.expect_err("dangling needs must refuse");
assert!(format!("{err:#}").contains("unknown sibling"), "{err:#}");
assert!(
persist_plan(&srv.endpoint, "", "nordisk", None, "idea", "s", &[]).is_err(),
"an empty plan is refused"
);
let f = fetch_funnel(&srv.endpoint, "", "nordisk").expect("Show");
assert!(
f.ideas.is_empty() && f.plans.is_empty(),
"nothing was written"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── the EMBEDDED + LIVE seam (host-injected read leg) ────────────────────
/// A fake [`LiveFunnelSource`]: `up` serves a funnel holding `ideas` (grown by
/// `submit`, so a submit→fetch round-trip is observable); `dead` fails every
/// consult with the connect-shaped error a dead server produces. No network.
struct FakeLive {
up: bool,
ideas: std::sync::Mutex<Vec<String>>,
}
impl FakeLive {
fn up(texts: &[&str]) -> Self {
Self {
up: true,
ideas: std::sync::Mutex::new(texts.iter().map(|s| s.to_string()).collect()),
}
}
fn dead() -> Self {
Self {
up: false,
ideas: std::sync::Mutex::new(Vec::new()),
}
}
}
impl LiveFunnelSource for FakeLive {
fn fetch(&self, _workspace: &str) -> Result<funnel_core::state::Funnel> {
if !self.up {
return Err(anyhow::anyhow!("tcp connect: connection refused")
.context("connect to nornir-server at http://127.0.0.1:1"));
}
let ideas = self.ideas.lock().unwrap();
let dump = pb::FunnelDump {
ideas: ideas
.iter()
.enumerate()
.map(|(i, t)| pb::FunnelDumpIdea {
id: format!("i-{:03}", i + 1),
text: t.clone(),
plans: Vec::new(),
source: "test".into(),
item_kind: "idea".into(),
})
.collect(),
};
let mut f = funnel_from_dump(&dump);
f.promote_ready();
Ok(f)
}
fn submit(&self, _workspace: &str, text: &str, _kind: IntakeKind) -> Result<String> {
if !self.up {
anyhow::bail!("connect to nornir-server at http://127.0.0.1:1: refused");
}
let mut v = self.ideas.lock().unwrap();
v.push(text.to_string());
Ok(format!("i-{:03}", v.len()))
}
fn describe(&self) -> String {
"fake live funnel".into()
}
}
/// The rendered plan-DAG's node TITLES (applied output, not a count).
fn plandag_titles(st: &serde_json::Value) -> Vec<String> {
st["plandag"]["nodes"]
.as_array()
.expect("plandag nodes")
.iter()
.filter_map(|n| n["title"].as_str().map(|s| s.to_string()))
.collect()
}
/// RED-WHEN-BROKEN — the missing-read-leg fix. A healthy injected live source
/// REPLACES the embedded content with the server's funnel: the live idea's own
/// words render in the plan-DAG, the demo seed is gone, and provenance says
/// `live: …` / `status: live`. If `refresh` ignored the source (the original
/// defect: submitted ideas landed in a store no pane read), the seeded
/// "release engine" fixture would still be on screen and this fails.
#[test]
fn live_source_replaces_embedded_content_and_names_live_provenance() {
let panel = FunnelPanel::new_embedded_seeded("nordisk")
.with_live_source(Box::new(FakeLive::up(&["korp raven autopilot"])));
// LAZY: attaching dials nothing — provenance says so until the first poll.
let st = Facet::state_json(&panel);
assert_eq!(
st["live_status"], "pending",
"no consult before the first refresh: {st}"
);
assert_eq!(
st["status"], "embedded",
"embedded content until consulted: {st}"
);
let mut panel = panel;
panel.refresh_blocking();
let st = Facet::state_json(&panel);
assert_eq!(
st["status"], "live",
"the rendered funnel is the server's: {st}"
);
assert_eq!(st["live_status"], "live", "{st}");
assert_eq!(st["funnel_source"], "live: fake live funnel", "{st}");
assert!(st["live_error"].is_null(), "{st}");
let titles = plandag_titles(&st);
assert!(
titles.iter().any(|t| t.contains("korp raven autopilot")),
"the live idea's words render in the DAG: {titles:?}"
);
assert!(
!titles.iter().any(|t| t.contains("release engine")),
"the embedded demo seed is NOT shown while live: {titles:?}"
);
}
/// The honesty guard: a DEAD live source keeps the embedded state AND names the
/// outage — `live_status: unreachable`, `funnel_source: embedded (live
/// unreachable — …)`, the error text surfaced — never a fabricated funnel, never
/// a silent fallback.
#[test]
fn dead_live_source_keeps_embedded_state_and_names_the_outage() {
let mut panel = FunnelPanel::new_embedded_seeded("nordisk")
.with_live_source(Box::new(FakeLive::dead()));
panel.refresh_blocking();
let st = Facet::state_json(&panel);
assert_eq!(st["status"], "embedded", "embedded content is kept: {st}");
assert_eq!(
st["live_status"], "unreachable",
"the outage is NAMED: {st}"
);
let src = st["funnel_source"].as_str().unwrap();
assert!(
src.starts_with("embedded (live unreachable"),
"provenance names the outage: {src}"
);
assert!(
st["live_error"].as_str().is_some(),
"the error text is surfaced: {st}"
);
let titles = plandag_titles(&st);
assert!(
titles.iter().any(|t| t.contains("release engine")),
"the seeded embedded funnel still renders: {titles:?}"
);
assert!(
st["node_count"].as_u64().unwrap() > 0,
"never blank on an outage: {st}"
);
}
/// While the live leg is UP the submit strip rides the WIRE (the same write leg
/// MCP uses) and the refresh makes the idea VISIBLE — submit → visible in one
/// action. The fake source records the submit, so a locally-swallowed submit
/// (the embedded engine absorbing it) could not pass the title assertion.
#[test]
fn submit_routes_over_the_live_wire_and_lands_visible() {
let mut panel = FunnelPanel::new_embedded_seeded("nordisk")
.with_live_source(Box::new(FakeLive::up(&[])));
panel.refresh_blocking();
let st = Facet::state_json(&panel);
assert_eq!(
st["status"], "live_empty",
"live and empty before the submit: {st}"
);
Facet::update_json(&mut panel, r#"{"submit_text":"raven autopilot brain"}"#);
let st = Facet::state_json(&panel);
assert_eq!(st["submit_status"], "ok", "{st}");
assert_eq!(st["submit_id"], "i-001", "the wire's id came back: {st}");
assert_eq!(st["status"], "live", "{st}");
let titles = plandag_titles(&st);
assert!(
titles.iter().any(|t| t.contains("raven autopilot brain")),
"the submitted idea is VISIBLE in the rendered DAG: {titles:?}"
);
}
/// An empty workspace with a live source is the thin pane's NoWarehouse rule,
/// mirrored: NAMED (`live_status: no_warehouse`) and NOT dialed — the dead fake
/// would report `unreachable` if a dial had happened.
#[test]
fn live_source_with_no_workspace_is_named_not_dialed() {
let mut panel = FunnelPanel::new_embedded("").with_live_source(Box::new(FakeLive::dead()));
panel.refresh();
let st = Facet::state_json(&panel);
assert_eq!(
st["live_status"], "no_warehouse",
"named, and no dial happened: {st}"
);
// the pane stays on its in-process funnel, which holds nothing (no local log, no seed)
assert_eq!(
st["status"], "embedded_empty",
"the embedded funnel is what is on screen: {st}"
);
}
/// RED-WHEN-BROKEN — the fat-mode fix. With **NO remote nornir configured** the
/// pane must default to EMBEDDED, render its in-process DAG, and report an
/// embedded-mode state — NEVER an unreachable / "no remote nornir" error. If the
/// pane regresses to thin/remote-by-default this fails (it would report
/// `unreachable` / a null DAG).
#[test]
fn embedded_is_the_default_and_renders_without_a_server() {
let panel = FunnelPanel::new_embedded_seeded("nordisk");
assert!(
panel.is_embedded(),
"no server configured ⇒ FAT/embedded default"
);
let st = Facet::state_json(&panel);
assert_eq!(st["kind"], "funnel");
assert_eq!(
st["mode"], "embedded",
"must default to fat/embedded, not thin/remote"
);
// A real DAG is rendered with NO server running.
assert!(
st["node_count"].as_u64().unwrap() > 0,
"embedded DAG is non-empty: {st}"
);
let status = st["status"].as_str().unwrap();
assert!(
status.starts_with("embedded"),
"embedded status, got `{status}`"
);
for bad in [
"unreachable",
"auth_failed",
"not_served",
"server_error",
"no_warehouse",
] {
assert_ne!(
status, bad,
"the embedded default must NEVER report a remote-failure state"
);
}
assert!(
st["error"].is_null(),
"no error in the embedded default: {st}"
);
}
/// The offline WRITE leg: submitting an idea into the embedded funnel grows the
/// in-process DAG (no server) and is accepted locally.
#[test]
fn embedded_submit_grows_the_dag_offline() {
let mut panel = FunnelPanel::new_embedded_seeded("nordisk");
let before = Facet::state_json(&panel)["node_count"].as_u64().unwrap();
Facet::update_json(
&mut panel,
r#"{"submit_text":"an offline idea","submit_kind":"idea"}"#,
);
let st = Facet::state_json(&panel);
assert_eq!(
st["submit_status"], "ok",
"embedded submit accepted with no server: {st}"
);
let after = st["node_count"].as_u64().unwrap();
assert!(
after > before,
"the in-process DAG grew {before} -> {after}: {st}"
);
assert_eq!(st["mode"], "embedded");
}
/// The thin/remote path is still available when a real endpoint is explicitly
/// configured (a dead port ⇒ the honest `unreachable` state, NOT embedded).
#[test]
fn explicit_remote_endpoint_still_uses_thin_mode() {
let panel = FunnelPanel::new("http://127.0.0.1:0", "", "nordisk");
assert!(!panel.is_embedded());
let st = Facet::state_json(&panel);
assert_eq!(st["mode"], "remote");
assert_eq!(
st["status"], "unreachable",
"explicit remote + dead port ⇒ honest unreachable"
);
}
/// **A dead source keeps the honest fallback in the 3D render too.** With an
/// explicit remote endpoint on a dead port, the pane names the outage (`status:
/// unreachable`, an `error` naming the endpoint) and the 3D graph reports ZERO
/// nodes and ZERO label texts — it never fabricates a spinning graph out of
/// nothing. The embedded default, by contrast, seeds the honest local fixture
/// whose labels really carry idea words.
#[test]
fn dead_source_keeps_honest_fallback_in_3d() {
let mut panel = FunnelPanel::new("http://127.0.0.1:0", "", "nordisk");
Facet::update_json(&mut panel, r#"{"dag_view":"graph3d"}"#);
let st = Facet::state_json(&panel);
assert_eq!(st["mode"], "remote");
assert_eq!(
st["status"], "unreachable",
"the outage is NAMED, not papered over: {st}"
);
let err = st["error"].as_str().expect("the error text is surfaced");
assert!(
err.contains("nornir-server"),
"the error names the dead source: {err}"
);
// The 3D report is honestly empty — no fabricated nodes, no fabricated words.
assert_eq!(st["graph3d"]["nodes"], 0, "no fabricated 3D nodes: {st}");
assert_eq!(
st["graph3d"]["label_texts"].as_array().unwrap().len(),
0,
"no fabricated words: {st}"
);
// The embedded default is the honest local fallback: real seeded idea words.
let emb = FunnelPanel::new_embedded_seeded("nordisk");
let st = Facet::state_json(&emb);
assert_eq!(st["mode"], "embedded");
let words: Vec<String> = st["graph3d"]["label_texts"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
assert!(
words.iter().any(|w| w.contains("release engine")),
"the embedded fallback's 3D labels carry the seeded idea's words: {words:?}"
);
assert_eq!(
st["graph3d"]["labels"], true,
"labels are ON by default: {st}"
);
assert_eq!(
st["graph3d"]["rotate"], true,
"the 3D graph spins by default: {st}"
);
}
/// A `Funnel.Show` dump reconstructs into a `funnel_core::Funnel` faithful enough
/// that the pure Plan-DAG projects the identical topology the local path builds —
/// the crux of the thin/remote wiring (mirrors nornir's reconstruct test, but
/// against THIS crate's re-implementation + generated `pb`).
#[test]
fn dump_reconstructs_into_a_projectable_funnel() {
let dump = pb::FunnelDump {
ideas: vec![pb::FunnelDumpIdea {
id: "i-001".into(),
text: "build the planner".into(),
source: "human:alice".into(),
item_kind: "idea".into(),
plans: vec![pb::FunnelDumpPlan {
id: "p-001".into(),
idea_id: "i-001".into(),
summary: "decompose".into(),
status: "Active".into(), // server ships the Debug repr
nodes: vec![
pb::FunnelDumpNode {
id: "n-001".into(),
kind: "code:write".into(),
title: "implement foo".into(),
status: "done".into(),
deps: vec![],
},
pb::FunnelDumpNode {
id: "n-002".into(),
kind: "code:fix".into(),
title: "implement bar".into(),
status: "in_progress".into(),
deps: vec!["n-001".into()],
},
pb::FunnelDumpNode {
id: "n-003".into(),
kind: "test:write".into(),
title: "test baz".into(),
status: "pending".into(),
deps: vec!["n-002".into(), "n-001".into()],
},
],
}],
}],
};
let f = funnel_from_dump(&dump);
assert_eq!(f.ideas.len(), 1);
assert_eq!(f.plans.len(), 1);
let plan = f.plans.values().next().unwrap();
assert_eq!(plan.status, funnel_core::event::PlanStatus::Active);
assert_eq!(plan.nodes.len(), 3);
use funnel_core::ids::NodeId;
assert_eq!(
plan.nodes[&NodeId::new("n-001")].status,
funnel_core::event::NodeStatus::Done
);
assert_eq!(
plan.nodes[&NodeId::new("n-002")].status,
funnel_core::event::NodeStatus::InProgress
);
assert_eq!(plan.edges.len(), 3);
// The pure Plan-DAG projects it: mother idea + 3 task nodes.
let dag = PlanDag::from_funnel(&f, None);
assert_eq!(dag.node_count(), 4, "mother + 3 task cards");
assert_eq!(dag.dep_edges(), 3, "the reconstructed dependency edges");
assert_eq!(
dag.mother_edges(),
3,
"one decomposition edge per task node"
);
let order = dag.topo_order().expect("acyclic");
let pos = |id: &str| order.iter().position(|x| x == id).unwrap();
assert!(pos("n-001") < pos("n-002"));
assert!(pos("n-002") < pos("n-003"));
}
/// A partial/edge-to-missing-node dump never panics (defensive reconstruction).
#[test]
fn dump_with_dangling_dep_is_tolerated() {
let dump = pb::FunnelDump {
ideas: vec![pb::FunnelDumpIdea {
id: "i-002".into(),
text: "x".into(),
source: "".into(),
item_kind: "".into(),
plans: vec![pb::FunnelDumpPlan {
id: "p-002".into(),
idea_id: "".into(),
summary: "s".into(),
status: "weird-unknown".into(),
nodes: vec![pb::FunnelDumpNode {
id: "n-010".into(),
kind: "code:write".into(),
title: "".into(),
status: "".into(),
deps: vec!["n-999".into()],
}],
}],
}],
};
let f = funnel_from_dump(&dump);
let plan = f.plans.values().next().unwrap();
assert_eq!(
plan.idea_id.as_str(),
"i-002",
"empty idea_id falls back to the enclosing idea"
);
assert_eq!(plan.status, funnel_core::event::PlanStatus::Draft);
assert!(plan.edges.is_empty(), "the dangling n-999 edge is dropped");
assert_eq!(plan.nodes.len(), 1);
}
/// The reconstructed funnel maps onto the `facett-plandag` model and exposes the
/// same robot-observable shape (node count + per-node cost-heat AS DATA).
#[test]
fn reconstructed_funnel_maps_onto_plandag_model() {
let dump = pb::FunnelDump {
ideas: vec![pb::FunnelDumpIdea {
id: "i-001".into(),
text: "planner".into(),
source: "".into(),
item_kind: "idea".into(),
plans: vec![pb::FunnelDumpPlan {
id: "p-001".into(),
idea_id: "i-001".into(),
summary: "decompose".into(),
status: "Active".into(),
nodes: vec![
pb::FunnelDumpNode {
id: "n-001".into(),
kind: "code:write".into(),
title: "foo".into(),
status: "done".into(),
deps: vec![],
},
pb::FunnelDumpNode {
id: "n-002".into(),
kind: "code:write".into(),
title: "bar".into(),
status: "in_progress".into(),
deps: vec!["n-001".into()],
},
],
}],
}],
};
let f = funnel_from_dump(&dump);
let dag = PlanDag::from_funnel(&f, None);
let view = view_from_dag(&dag);
let st = Facet::state_json(&view);
// mother + 2 task nodes.
assert_eq!(st["node_count"], 3);
for n in st["nodes"].as_array().unwrap() {
let h = n["heat"].as_f64().unwrap();
assert!(
(0.0..=1.0).contains(&h),
"cost-heat rides as data in [0,1]: {h}"
);
}
}
/// **DISTINCT failure states (the funnel-offline mislabel fix).** With a warehouse
/// SELECTED but the server on a dead port, the pane is cleanly `unreachable` (NOT
/// the old "offline" lie); with NO warehouse selected (empty workspace) it is
/// `no_warehouse` and never even touches the network. RED-when-broken: collapse the
/// two into one catch-all and one of these assertions fails.
#[test]
fn distinct_offline_and_no_warehouse_states() {
// A warehouse is selected, but the server is unreachable (dead port).
let mut p = FunnelPanel::new("http://127.0.0.1:0", "", "nordisk");
assert_eq!(
p.conn,
Conn::Unreachable,
"selected + dead port ⇒ unreachable, not 'offline'"
);
let st = Facet::state_json(&p);
assert_eq!(st["status"], "unreachable");
assert_eq!(st["node_count"], 0);
assert!(
st["error"].is_string(),
"the error is captured for the hover tooltip"
);
p.refresh();
assert_eq!(
p.conn,
Conn::Unreachable,
"a still-dead server stays cleanly unreachable"
);
// NO warehouse selected (empty workspace): not a server problem at all.
let np = FunnelPanel::new("http://127.0.0.1:0", "", "");
assert_eq!(
np.conn,
Conn::NoWarehouse,
"empty workspace ⇒ no_warehouse (no network touched)"
);
assert_eq!(Facet::state_json(&np)["status"], "no_warehouse");
assert!(
Facet::state_json(&np)["error"].is_null(),
"no-warehouse is not an error state"
);
}
/// **`classify_conn` maps each gRPC failure to the RIGHT distinct state** — the
/// crux of the mislabel fix, unit-tested without a server. RED-when-broken: mis-map
/// any code (e.g. Unauthenticated → ServerError) and an assert fails.
#[test]
fn classify_conn_distinguishes_the_failure_modes() {
let auth = anyhow::Error::from(tonic::Status::unauthenticated(
"missing or invalid bearer token",
))
.context("Funnel.Show RPC");
assert_eq!(classify_conn(&auth), Conn::AuthFailed);
let notfound = anyhow::Error::from(tonic::Status::not_found(
"workspace `nordisk` is not served",
))
.context("Funnel.Show RPC");
assert_eq!(classify_conn(¬found), Conn::NotServed);
let internal =
anyhow::Error::from(tonic::Status::internal("boom")).context("Funnel.Show RPC");
assert_eq!(classify_conn(&internal), Conn::ServerError);
let connect = anyhow::anyhow!("some io error")
.context("connect to nornir-server at http://127.0.0.1:0");
assert_eq!(classify_conn(&connect), Conn::Unreachable);
facett_core::testmatrix::emit(
"nornir-funnel-embed",
"classify_conn_distinguishes_failure_modes",
classify_conn(&auth) == Conn::AuthFailed
&& classify_conn(¬found) == Conn::NotServed
&& classify_conn(&connect) == Conn::Unreachable,
"the funnel names auth-failed vs not-served vs unreachable vs server-error distinctly — never the 'offline' mislabel when the server is up",
);
}
/// **`set_workspace` binds the pane to a selected warehouse** (nornir's per-tab
/// set_workspace, mirrored): selecting a name re-fetches for THAT warehouse;
/// selecting the empty name returns to the clean no_warehouse state. Drives the
/// dead-port pane so no real server is needed.
#[test]
fn set_workspace_rebinds_and_refetches() {
let mut p = FunnelPanel::new("http://127.0.0.1:0", "", "");
assert_eq!(p.conn, Conn::NoWarehouse);
// Select a warehouse over the JSON seam → binds + re-fetches (dead port ⇒ unreachable).
Facet::update_json(&mut p, r#"{"set_workspace":"nordisk"}"#);
assert_eq!(p.workspace(), "nordisk");
assert_eq!(Facet::state_json(&p)["workspace"], "nordisk");
assert_eq!(
p.conn,
Conn::Unreachable,
"a bound-but-unreachable warehouse ⇒ unreachable"
);
// Deselect (empty) → back to no_warehouse, no network.
Facet::update_json(&mut p, r#"{"set_workspace":""}"#);
assert_eq!(p.conn, Conn::NoWarehouse);
}
#[test]
fn poll_due_respects_interval_and_toggle() {
let p = FunnelPanel::new("http://127.0.0.1:0", "", ""); // offline; auto_poll on by default
assert!(p.auto_poll);
// last_poll_s starts at 0.0, interval = DEFAULT_POLL_INTERVAL_S (1.5s).
assert!(!p.poll_due(0.5), "not due before one interval");
assert!(
p.poll_due(DEFAULT_POLL_INTERVAL_S),
"due at exactly one interval"
);
assert!(p.poll_due(9.0), "due well past the interval");
let mut p = p;
p.set_auto_poll(false);
assert!(!p.poll_due(9.0), "never due while paused");
}
#[test]
fn update_json_toggles_auto_poll_in_state() {
let mut p = FunnelPanel::new("http://127.0.0.1:0", "", "");
Facet::update_json(&mut p, r#"{"auto_poll": false}"#);
assert_eq!(Facet::state_json(&p)["auto_poll"], serde_json::json!(false));
Facet::update_json(&mut p, r#"{"auto_poll": true}"#);
assert_eq!(Facet::state_json(&p)["auto_poll"], serde_json::json!(true));
assert_eq!(
Facet::state_json(&p)["poll_interval_s"],
serde_json::json!(DEFAULT_POLL_INTERVAL_S)
);
}
/// Build a `Funnel.Show` dump with one idea/plan carrying `task_titles` task
/// nodes wired in a chain (each depends on the previous). Lets a test grow the
/// plan by handing successively longer title lists.
fn dump_with_tasks(task_titles: &[&str]) -> pb::FunnelDump {
let nodes: Vec<pb::FunnelDumpNode> = task_titles
.iter()
.enumerate()
.map(|(i, t)| pb::FunnelDumpNode {
id: format!("n-{:03}", i + 1),
kind: "code:write".into(),
title: (*t).into(),
status: if i == 0 { "done" } else { "pending" }.into(),
deps: if i == 0 {
vec![]
} else {
vec![format!("n-{:03}", i)]
},
})
.collect();
pb::FunnelDump {
ideas: vec![pb::FunnelDumpIdea {
id: "i-001".into(),
text: "grow the plan".into(),
source: "human:alice".into(),
item_kind: "idea".into(),
plans: vec![pb::FunnelDumpPlan {
id: "p-001".into(),
idea_id: "i-001".into(),
summary: "decompose".into(),
status: "Active".into(),
nodes,
}],
}],
}
}
/// Project a dump into the pane's Plan-DAG node count (mother idea + task
/// nodes) — the exact number the live pane renders + folds into `state_json`.
fn plandag_node_count(dump: &pb::FunnelDump) -> usize {
let f = funnel_from_dump(dump);
PlanDag::from_funnel(&f, None).node_count()
}
/// **The DAG GROWS as nodes land** (super-priority C): each successive
/// `Funnel.Show` poll that carries more task nodes reconstructs into a
/// strictly larger Plan-DAG — the exact behaviour the pane's auto-poll drives
/// on `dwarf`. Records ONE test-matrix cell for the growth invariant.
#[test]
fn plandag_grows_as_nodes_land() {
// 1 task → 2 tasks → 4 tasks. node_count = mother(1) + tasks.
let counts: Vec<usize> = [&["a"][..], &["a", "b"][..], &["a", "b", "c", "d"][..]]
.iter()
.map(|titles| plandag_node_count(&dump_with_tasks(titles)))
.collect();
assert_eq!(
counts,
vec![2, 3, 5],
"mother + N task nodes as the plan grows"
);
let monotonic = counts.windows(2).all(|w| w[1] > w[0]);
assert!(
monotonic,
"each poll with more nodes renders a strictly larger DAG: {counts:?}"
);
facett_core::testmatrix::emit(
"nornir-funnel-embed",
"plandag_grows_as_nodes_land",
monotonic && counts.first() == Some(&2) && counts.last() == Some(&5),
"successive Funnel.Show polls with more task nodes reconstruct into a strictly larger Plan-DAG (the pane's auto-poll growth)",
);
}
/// The submit strip's default state is robot-observable and empty: idea kind,
/// blank draft, no submit yet — the shape a robot asserts before typing.
#[test]
fn submit_strip_default_state_is_observable() {
let p = FunnelPanel::new("http://127.0.0.1:0", "", "");
let st = Facet::state_json(&p);
assert_eq!(st["draft"], "");
assert_eq!(st["intake_kind"], "idea");
assert_eq!(st["submit_status"], "none");
assert!(st["submit_id"].is_null());
}
/// Setting the draft + intake kind through the JSON seam (no keyboard) lands in
/// `state_json` — the headless path a robot uses to "type" into the pane.
#[test]
fn update_json_sets_draft_and_kind() {
let mut p = FunnelPanel::new("http://127.0.0.1:0", "", "");
Facet::update_json(
&mut p,
r#"{"draft": "make releases atomic", "intake_kind": "error"}"#,
);
let st = Facet::state_json(&p);
assert_eq!(st["draft"], "make releases atomic");
assert_eq!(st["intake_kind"], "error");
assert_eq!(
st["submit_status"], "none",
"setting the draft does not submit"
);
}
/// A blank draft submit is a no-op (never hits the network, never records an
/// outcome) — the disabled-button behaviour, verified headless.
#[test]
fn blank_submit_is_a_noop() {
let mut p = FunnelPanel::new("http://127.0.0.1:0", "", "");
p.submit_draft();
assert_eq!(Facet::state_json(&p)["submit_status"], "none");
Facet::update_json(&mut p, r#"{"draft": " "}"#); // whitespace only
Facet::update_json(&mut p, r#"{"submit": true}"#);
assert_eq!(
Facet::state_json(&p)["submit_status"],
"none",
"whitespace-only draft is blank"
);
}
/// **The WRITE leg is offline-safe.** Submitting against a dead server records a
/// clean `error` outcome (never a panic) and PRESERVES the draft so the typed
/// text isn't lost — the same non-panic discipline as the read path. Records the
/// test-matrix cell for the funnel write leg.
#[test]
fn submit_offline_is_clean_error_and_keeps_draft() {
let mut p = FunnelPanel::new("http://127.0.0.1:0", "", "nordisk");
Facet::update_json(
&mut p,
r#"{"submit_text": "grow the plan-DAG", "submit_kind": "idea"}"#,
);
let st = Facet::state_json(&p);
assert_eq!(
st["submit_status"], "error",
"offline submit → clean error, no panic"
);
assert!(
st["submit_error"].is_string(),
"the error is captured for the hover tooltip"
);
assert_eq!(
st["draft"], "grow the plan-DAG",
"a failed submit keeps the typed text"
);
// The pane stays usable (still cleanly unreachable, not wedged).
assert_eq!(st["status"], "unreachable");
facett_core::testmatrix::emit(
"nornir-funnel-embed",
"funnel_write_submit_idea_offline_safe",
st["submit_status"] == "error" && st["draft"] == "grow the plan-DAG",
"typing an idea/error and submitting calls Funnel.SubmitIdea over gRPC; an offline server → clean error, draft preserved, no panic (the WRITE leg)",
);
}
/// **The growth history de-dups + caps.** Consecutive identical polls collapse
/// (the trail records only *changes*), and the ring never exceeds `HISTORY_CAP`.
/// RED-when-broken: drop the de-dup and `len` jumps to 5; drop the cap and it
/// exceeds `HISTORY_CAP`.
#[test]
fn history_records_growth_dedups_and_caps() {
let mut h = FunnelHistory::default();
// Two identical polls collapse; then two growth steps.
assert!(h.record(2, 1));
assert!(!h.record(2, 1), "an identical consecutive poll collapses");
assert!(h.record(3, 2));
assert!(!h.record(3, 2));
assert!(h.record(5, 4));
assert_eq!(h.len(), 3, "only the change points are kept");
assert_eq!(
h.samples().iter().map(|s| s.node_count).collect::<Vec<_>>(),
vec![2, 3, 5]
);
assert_eq!(h.peak_nodes(), 5);
assert_eq!(h.node_growth(), 3, "net growth from first(2) to latest(5)");
// Cap: push HISTORY_CAP + 10 distinct samples; the ring holds exactly the cap
// and keeps the NEWEST (oldest dropped).
let mut h = FunnelHistory::default();
for i in 0..(HISTORY_CAP + 10) {
h.record(i, 0);
}
assert_eq!(h.len(), HISTORY_CAP, "capped at HISTORY_CAP");
assert_eq!(
h.latest_node_count(),
HISTORY_CAP + 9,
"the newest sample survives; the oldest were dropped"
);
h.clear();
assert!(h.is_empty(), "clear empties the trail");
}
/// **The DAG's growth is RECORDED as it grows** (the "addera historik" super-prio):
/// successive `Funnel.Show` polls carrying more task nodes push a strictly growing
/// node-count into the history trail (a re-poll with NO change collapses, so the
/// trail counts genuine growth moments, not ticks). Records the test-matrix cell.
#[test]
fn funnel_history_records_the_dag_growing() {
let plans: Vec<&[&str]> = vec![
&["a"][..],
&["a"][..], // an unchanged re-poll — must collapse
&["a", "b"][..],
&["a", "b", "c", "d"][..],
];
let mut h = FunnelHistory::default();
for titles in &plans {
let n = plandag_node_count(&dump_with_tasks(titles));
h.record(n, 0);
}
// node_count = mother(1) + tasks: 2, (2 collapses), 3, 5 → three change points.
let trail: Vec<usize> = h.samples().iter().map(|s| s.node_count).collect();
assert_eq!(
trail,
vec![2, 3, 5],
"the unchanged re-poll collapsed; growth recorded"
);
let monotonic = trail.windows(2).all(|w| w[1] > w[0]);
assert!(monotonic && h.node_growth() == 3);
facett_core::testmatrix::emit(
"nornir-funnel-embed",
"funnel_history_addera_historik",
monotonic && trail == vec![2, 3, 5] && h.node_growth() == 3,
"the live pane records the funnel DAG's node-count growth across polls (de-duped change trail) — the 'addera historik' growth history",
);
}
/// The offline pane exposes the history fields with the correct EMPTY shape (a
/// dead server never recorded a sample, but the trail is always present as data).
#[test]
fn offline_pane_exposes_empty_history_shape() {
let p = FunnelPanel::new("http://127.0.0.1:0", "", "");
let st = Facet::state_json(&p);
assert!(
st["history"].as_array().unwrap().is_empty(),
"no polls landed ⇒ empty trail"
);
assert_eq!(st["history_len"], 0);
assert_eq!(st["history_peak_nodes"], 0);
assert_eq!(st["node_growth"], 0);
}
#[test]
fn live_poll_offline_stays_unavailable_no_panic() {
// Headless, in-code: a forced live poll on a dead server (warehouse selected)
// keeps the clean `unreachable` state — the auto-poll loop can never wedge or
// panic the UI.
let mut p = FunnelPanel::new("http://127.0.0.1:0", "", "nordisk");
Facet::update_json(&mut p, r#"{"poll": true}"#);
let st = Facet::state_json(&p);
assert_eq!(st["status"], "unreachable");
assert_eq!(st["node_count"], 0);
// Test-matrix cell: the live-poll seam is exercised in code (self-driving,
// no AI, no manual run). No-op unless `--features testmatrix`.
facett_core::testmatrix::emit(
"nornir-funnel-embed",
"live_auto_poll_offline_safe",
st["status"] == "unreachable",
"forced poll on a dead server → clean unreachable, no panic; auto_poll toggles via state seam",
);
}
// ── run history + bottleneck ("addera historik", the NEXT layer) ────────────
The file goes on; the console shows all of it.