From 185c59507abfa0262c13211b37a1b358bf54c3d5 Mon Sep 17 00:00:00 2001
From: Jeremy
Date: Thu, 22 Oct 2026 02:32:53 +0000
Subject: [PATCH 1/5] feat(nodelab): author pinned host-admitted workflows
---
.../examples/nodelab_host.rs | 187 ++++++++++++
.../src/custom_nodes.rs | 2 +
.../src/custom_nodes/authoring.rs | 37 +++
.../src/graph/connection_insert.rs | 7 +
.../src/graph/connection_intent.rs | 1 +
.../src/graph/session.rs | 16 +-
.../src/graph/session_connection_api.rs | 21 +-
.../src/graph/session_contract.rs | 22 +-
.../src/graph/session_node_api.rs | 45 +++
.../src/graph/session_state.rs | 18 +-
.../src/workflow.rs | 1 +
.../src/workflow/custom_node_authoring_api.rs | 112 ++++++++
.../src/workflow/service_config.rs | 20 +-
.../src/workflow/tests/custom_nodes.rs | 269 ++++++++++++++++++
docs/custom-nodes.md | 48 +++-
.../evidence/nodelab-authoring/browser.log.gz | Bin 0 -> 441 bytes
.../native-unavailable.log.gz | Bin 0 -> 2190 bytes
.../nodelab-authoring/qualification.json | 54 ++++
.../execution-ledger.md | 33 +++
.../issues.md | 4 +-
.../plan.md | 58 ++++
scripts/README.md | 17 ++
scripts/check-nodelab-browser.mjs | 172 +++++++++++
src-tauri/src/app_setup.rs | 3 +
src-tauri/src/workflow/commands.rs | 34 +++
src/components/workbench/NodeLabPage.svelte | 89 +++++-
src/components/workbench/nodeLabController.ts | 86 ++++++
.../workbench/nodeLabPresenters.test.ts | 15 +-
src/components/workbench/nodeLabPresenters.ts | 15 +-
.../workflow/customNodeAuthoring.test.ts | 143 ++++++++++
src/services/workflow/customNodeAuthoring.ts | 184 ++++++++++++
31 files changed, 1647 insertions(+), 66 deletions(-)
create mode 100644 crates/pantograph-workflow-service/examples/nodelab_host.rs
create mode 100644 crates/pantograph-workflow-service/src/custom_nodes/authoring.rs
create mode 100644 crates/pantograph-workflow-service/src/workflow/custom_node_authoring_api.rs
create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz
create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/native-unavailable.log.gz
create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json
create mode 100644 scripts/check-nodelab-browser.mjs
create mode 100644 src/components/workbench/nodeLabController.ts
create mode 100644 src/services/workflow/customNodeAuthoring.test.ts
create mode 100644 src/services/workflow/customNodeAuthoring.ts
diff --git a/crates/pantograph-workflow-service/examples/nodelab_host.rs b/crates/pantograph-workflow-service/examples/nodelab_host.rs
new file mode 100644
index 000000000..c1b4d82c1
--- /dev/null
+++ b/crates/pantograph-workflow-service/examples/nodelab_host.rs
@@ -0,0 +1,187 @@
+//! Isolated stdio host for NodeLab authoring qualification. No runtime code loader.
+//! A verifier supplies the loopback HTTP transport; EOF drops the temporary store.
+use async_trait::async_trait;
+use node_engine::{Context, ExecutorExtensions, NodeExecutor};
+use pantograph_node_contracts::{
+ NodeAuthoringMetadata, NodeCategory, NodeExecutionSemantics, NodeTypeContract, PortContract,
+ PortRequirement, PortValueType,
+};
+use pantograph_workflow_service::{
+ custom_nodes::{authoring::CustomNodeDraftRequest, CustomNodeCatalog, TrustedCustomNode},
+ *,
+};
+use serde::{de::DeserializeOwned, Serialize};
+use serde_json::{json, Value};
+use std::{
+ collections::HashMap,
+ io::{BufRead, Write},
+ sync::Arc,
+};
+
+struct Uppercase;
+#[async_trait]
+impl NodeExecutor for Uppercase {
+ async fn execute(
+ &self,
+ _: &str,
+ inputs: HashMap,
+ _: &Context,
+ _: &ExecutorExtensions,
+ ) -> node_engine::Result> {
+ Ok(HashMap::from([(
+ "output".into(),
+ json!(inputs["input"]
+ .as_str()
+ .ok_or_else(|| node_engine::NodeEngineError::ExecutionFailed(
+ "string input required".into()
+ ))?
+ .to_uppercase()),
+ )]))
+ }
+}
+fn catalog() -> Result, Box> {
+ let mut output = PortContract::output("output".parse()?, "Output", PortValueType::String);
+ output.requirement = PortRequirement::Required;
+ let contract = NodeTypeContract {
+ node_type: "host-uppercase".parse()?,
+ category: NodeCategory::Processing,
+ label: "Host uppercase".into(),
+ description: "Pure string transform compiled into this isolated Rust host".into(),
+ inputs: vec![PortContract::input(
+ "input".parse()?,
+ "Input",
+ PortValueType::String,
+ PortRequirement::Required,
+ )],
+ outputs: vec![output],
+ execution_semantics: NodeExecutionSemantics::Batch,
+ capability_requirements: vec![],
+ inference_tasks: vec![],
+ authoring: NodeAuthoringMetadata::default(),
+ contract_version: Some("1.0.0".into()),
+ contract_digest: None,
+ };
+ let digest = |text: &str| format!("blake3:{}", blake3::hash(text.as_bytes()));
+ Ok(Arc::new(CustomNodeCatalog::try_new(vec![
+ TrustedCustomNode::admit(
+ contract,
+ digest("nodelab_host.rs uppercase v1"),
+ digest("isolated Rust authoring host v1"),
+ Arc::new(Uppercase),
+ )?,
+ ])?))
+}
+fn decode(value: Value) -> Result {
+ serde_json::from_value(value).map_err(|e| WorkflowServiceError::InvalidRequest(e.to_string()))
+}
+fn encode(value: impl Serialize) -> Result {
+ serde_json::to_value(value).map_err(|e| WorkflowServiceError::Internal(e.to_string()))
+}
+fn string(args: &Value, name: &str) -> Result {
+ args[name]
+ .as_str()
+ .map(str::to_owned)
+ .ok_or_else(|| WorkflowServiceError::InvalidRequest(format!("{name} is required")))
+}
+async fn dispatch(
+ service: &WorkflowService,
+ store: &FileSystemWorkflowGraphStore,
+ command: &str,
+ args: Value,
+) -> Result {
+ let edit = || -> Result {
+ Ok(WorkflowGraphEditSessionGraphRequest {
+ session_id: string(&args, "executionId")?,
+ })
+ };
+ match command {
+ "get_custom_node_authoring_catalog" => encode(service.custom_node_authoring_catalog()),
+ "create_custom_node_draft" => encode(
+ service
+ .create_custom_node_draft(decode::(
+ args["request"].clone(),
+ )?)
+ .await?,
+ ),
+ "get_node_lab_draft" => encode(
+ service
+ .workflow_graph_get_edit_session_graph(edit()?)
+ .await?,
+ ),
+ "create_workflow_execution_session" => encode(
+ service
+ .workflow_graph_create_edit_session(WorkflowGraphEditSessionCreateRequest {
+ graph: decode(args["graph"].clone())?,
+ workflow_id: args["workflowId"].as_str().map(str::to_owned),
+ })
+ .await?,
+ ),
+ "update_node_data" => encode(
+ service
+ .workflow_graph_update_node_data(WorkflowGraphUpdateNodeDataRequest {
+ session_id: string(&args, "executionId")?,
+ node_id: string(&args, "nodeId")?,
+ data: args["data"].clone(),
+ })
+ .await?,
+ ),
+ "save_workflow" => encode(
+ service
+ .workflow_graph_save(
+ store,
+ WorkflowGraphSaveRequest {
+ name: string(&args, "name")?,
+ graph: decode(args["graph"].clone())?,
+ },
+ )?
+ .path,
+ ),
+ "load_workflow" => encode(service.workflow_graph_load(
+ store,
+ WorkflowGraphLoadRequest {
+ path: string(&args, "path")?,
+ },
+ )?),
+ "remove_execution" => {
+ service
+ .workflow_graph_close_edit_session(WorkflowGraphEditSessionCloseRequest {
+ session_id: edit()?.session_id,
+ })
+ .await?;
+ Ok(Value::Null)
+ }
+ _ => Err(WorkflowServiceError::CapabilityViolation(
+ "unsupported authoring host command".into(),
+ )),
+ }
+}
+#[tokio::main]
+async fn main() -> Result<(), Box> {
+ let directory = tempfile::tempdir()?;
+ let store = FileSystemWorkflowGraphStore::new(directory.path());
+ let service = if std::env::args().any(|arg| arg == "--empty-catalog") {
+ WorkflowService::new()
+ } else {
+ WorkflowService::with_trusted_custom_nodes(catalog()?)
+ };
+ for line in std::io::stdin().lock().lines() {
+ let result = match serde_json::from_str::(&line?) {
+ Ok(request) => match request["command"].as_str() {
+ Some(command) => dispatch(&service, &store, command, request["args"].clone()).await,
+ None => Err(WorkflowServiceError::InvalidRequest(
+ "command is required".into(),
+ )),
+ },
+ Err(error) => Err(WorkflowServiceError::InvalidRequest(error.to_string())),
+ };
+ let response = match result {
+ Ok(value) => json!({"ok": true, "value": value}),
+ Err(error) => {
+ json!({"ok": false, "error": serde_json::from_str::(&error.to_envelope_json())?})
+ }
+ };
+ println!("{}", serde_json::to_string(&response)?);
+ std::io::stdout().flush()?;
+ }
+ Ok(())
+}
diff --git a/crates/pantograph-workflow-service/src/custom_nodes.rs b/crates/pantograph-workflow-service/src/custom_nodes.rs
index a6f8bfca3..90b245b5e 100644
--- a/crates/pantograph-workflow-service/src/custom_nodes.rs
+++ b/crates/pantograph-workflow-service/src/custom_nodes.rs
@@ -355,3 +355,5 @@ pub(crate) fn validate_payload(
#[cfg(test)]
pub(crate) mod tests;
+
+pub mod authoring;
diff --git a/crates/pantograph-workflow-service/src/custom_nodes/authoring.rs b/crates/pantograph-workflow-service/src/custom_nodes/authoring.rs
new file mode 100644
index 000000000..8dc69b4b2
--- /dev/null
+++ b/crates/pantograph-workflow-service/src/custom_nodes/authoring.rs
@@ -0,0 +1,37 @@
+//! Read-only host catalog and exactly pinned graph authoring. No execution/loading API.
+use super::CustomNodeIdentity;
+use serde::{Deserialize, Serialize};
+
+/// Version 1 is a deliberately narrow projection of admitted string-port contracts.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct CustomNodeAuthoringCatalog {
+ pub contract_version: u16,
+ pub nodes: Vec,
+}
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct CustomNodeAuthoringEntry {
+ pub identity: CustomNodeIdentity,
+ pub label: String,
+ pub description: String,
+ pub trust: CustomNodeAuthoringTrust,
+ pub inputs: Vec,
+ pub outputs: Vec,
+}
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum CustomNodeAuthoringTrust {
+ TrustedInProcess,
+}
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct CustomNodeAuthoringPort {
+ pub id: String,
+ pub label: String,
+}
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct CustomNodeDraftRequest {
+ pub identity: CustomNodeIdentity,
+}
diff --git a/crates/pantograph-workflow-service/src/graph/connection_insert.rs b/crates/pantograph-workflow-service/src/graph/connection_insert.rs
index bd49a8b39..f267b7cd3 100644
--- a/crates/pantograph-workflow-service/src/graph/connection_insert.rs
+++ b/crates/pantograph-workflow-service/src/graph/connection_insert.rs
@@ -41,6 +41,13 @@ fn resolve_insert_definition<'a>(
registry: &'a NodeRegistry,
node_type: &str,
) -> Result<&'a NodeDefinition, ConnectionRejection> {
+ if registry.custom_nodes.get(node_type).is_some() {
+ return Err(ConnectionRejection {
+ reason: ConnectionRejectionReason::UnknownInsertNodeType,
+ message: "custom-node insertion by name is unsupported; add an exactly pinned node and connect its endpoints".into(),
+ contract_diagnostic: None,
+ });
+ }
registry
.get_definition(node_type)
.ok_or_else(|| ConnectionRejection {
diff --git a/crates/pantograph-workflow-service/src/graph/connection_intent.rs b/crates/pantograph-workflow-service/src/graph/connection_intent.rs
index 1360f02c0..a0c2caf1e 100644
--- a/crates/pantograph-workflow-service/src/graph/connection_intent.rs
+++ b/crates/pantograph-workflow-service/src/graph/connection_intent.rs
@@ -506,6 +506,7 @@ pub fn connection_candidates_with_surfaces(
let mut insertable_node_types = registry
.all_definitions()
.into_iter()
+ .filter(|definition| registry.custom_nodes.get(&definition.node_type).is_none())
.filter_map(|definition| {
let mut matching_input_port_ids = definition
.inputs
diff --git a/crates/pantograph-workflow-service/src/graph/session.rs b/crates/pantograph-workflow-service/src/graph/session.rs
index d09eaa2a6..3d940e3bf 100644
--- a/crates/pantograph-workflow-service/src/graph/session.rs
+++ b/crates/pantograph-workflow-service/src/graph/session.rs
@@ -75,6 +75,7 @@ fn dirty_tasks_from_seed_nodes_unique(graph: &WorkflowGraph, node_ids: &[String]
}
pub struct GraphSessionStore {
+ node_registry: Arc,
sessions: RwLock>,
validation_state: Arc,
validation_lifecycle: Arc,
@@ -136,6 +137,7 @@ impl GraphSessionStore {
dependency_provider: SharedDependencyEnvironmentProvider,
) -> Self {
Self {
+ node_registry: Arc::new(super::registry::NodeRegistry::new()),
sessions: RwLock::new(HashMap::new()),
validation_state: Arc::new(CurrentInferenceValidationStateStore::new()),
validation_lifecycle: Arc::new(WorkflowGraphValidationLifecycleOwner::new()),
@@ -146,6 +148,14 @@ impl GraphSessionStore {
}
}
+ pub(crate) fn with_node_registry(
+ mut self,
+ registry: Arc,
+ ) -> Self {
+ self.node_registry = registry;
+ self
+ }
+
pub async fn set_validation_lifecycle_event_sink(
&self,
event_sink: Option>,
@@ -159,7 +169,11 @@ impl GraphSessionStore {
workflow_id: Option,
) -> WorkflowGraphEditSessionCreateResponse {
let session_id = Uuid::new_v4().to_string();
- let session = Arc::new(Mutex::new(GraphEditSession::new(graph, workflow_id)));
+ let session = Arc::new(Mutex::new(GraphEditSession::new(
+ graph,
+ workflow_id,
+ self.node_registry.clone(),
+ )));
let graph_revision = {
let state = session.lock().await;
state.graph.compute_fingerprint()
diff --git a/crates/pantograph-workflow-service/src/graph/session_connection_api.rs b/crates/pantograph-workflow-service/src/graph/session_connection_api.rs
index 22a97ae4e..97f717675 100644
--- a/crates/pantograph-workflow-service/src/graph/session_connection_api.rs
+++ b/crates/pantograph-workflow-service/src/graph/session_connection_api.rs
@@ -13,7 +13,6 @@ use super::super::inference_validation_state::{
WorkflowGraphCurrentConnectionSurfacesStateRequest,
};
use super::super::memory_impact::graph_memory_impact_from_graph_change;
-use super::super::registry::NodeRegistry;
use super::super::session_event::{dirty_tasks_from_seed_nodes, graph_modified_event};
use super::super::session_graph::sync_embedding_emit_metadata_flags;
use super::super::session_types::{
@@ -39,10 +38,10 @@ impl GraphSessionStore {
request.graph_revision.as_deref(),
)
.await?;
- let registry = NodeRegistry::new();
+ let registry = self.node_registry.as_ref();
connection_candidates_with_surfaces(
&graph,
- ®istry,
+ registry,
InferenceConnectionSurfaceView::new(&connection_surfaces.surfaces),
request.source_anchor,
request.graph_revision.as_deref(),
@@ -65,10 +64,10 @@ impl GraphSessionStore {
let mut state = handle.lock().await;
state.touch();
let before_graph = state.graph.clone();
- let registry = NodeRegistry::new();
+ let registry = self.node_registry.as_ref();
if let Err(rejection) = commit_connection_with_surfaces(
&state.graph,
- ®istry,
+ registry,
InferenceConnectionSurfaceView::new(&connection_surfaces.surfaces),
&request.graph_revision,
&request.source_anchor,
@@ -191,10 +190,10 @@ impl GraphSessionStore {
let mut state = handle.lock().await;
state.touch();
let before_graph = state.graph.clone();
- let registry = NodeRegistry::new();
+ let registry = self.node_registry.as_ref();
let (inserted_node, inserted_edge) = match insert_node_and_connect(
&state.graph,
- ®istry,
+ registry,
&request.graph_revision,
&request.source_anchor,
&request.node_type,
@@ -252,11 +251,11 @@ impl GraphSessionStore {
let handle = self.get_session_handle(&request.session_id).await?;
let mut state = handle.lock().await;
state.touch();
- let registry = NodeRegistry::new();
+ let registry = self.node_registry.as_ref();
match preview_node_insert_on_edge(
&state.graph,
- ®istry,
+ registry,
&request.graph_revision,
&request.edge_id,
&request.node_type,
@@ -283,11 +282,11 @@ impl GraphSessionStore {
let mut state = handle.lock().await;
state.touch();
let before_graph = state.graph.clone();
- let registry = NodeRegistry::new();
+ let registry = self.node_registry.as_ref();
let (inserted_node, incoming_edge, outgoing_edge, bridge) = match insert_node_on_edge(
&state.graph,
- ®istry,
+ registry,
&request.graph_revision,
&request.edge_id,
&request.node_type,
diff --git a/crates/pantograph-workflow-service/src/graph/session_contract.rs b/crates/pantograph-workflow-service/src/graph/session_contract.rs
index ee8d764fc..d62215fc5 100644
--- a/crates/pantograph-workflow-service/src/graph/session_contract.rs
+++ b/crates/pantograph-workflow-service/src/graph/session_contract.rs
@@ -88,11 +88,28 @@ fn build_graph_session_response(
build_graph_session_response_with_state(session_id, graph, workflow_event, None)
}
+#[cfg(test)]
pub(crate) fn build_graph_session_response_with_state(
session_id: &str,
graph: &super::types::WorkflowGraph,
workflow_event: Option,
projection: Option<&WorkflowGraphSessionStateProjection>,
+) -> WorkflowGraphEditSessionGraphResponse {
+ build_graph_session_response_with_registry(
+ session_id,
+ graph,
+ &NodeRegistry::new(),
+ workflow_event,
+ projection,
+ )
+}
+
+pub(crate) fn build_graph_session_response_with_registry(
+ session_id: &str,
+ graph: &super::types::WorkflowGraph,
+ registry: &NodeRegistry,
+ workflow_event: Option,
+ projection: Option<&WorkflowGraphSessionStateProjection>,
) -> WorkflowGraphEditSessionGraphResponse {
let graph_revision = graph.compute_fingerprint();
WorkflowGraphEditSessionGraphResponse {
@@ -106,10 +123,7 @@ pub(crate) fn build_graph_session_response_with_state(
workflow_event.as_ref(),
projection,
)),
- graph_diagnostics: validate_workflow_graph_contract_diagnostics(
- graph,
- &NodeRegistry::new(),
- ),
+ graph_diagnostics: validate_workflow_graph_contract_diagnostics(graph, registry),
}
}
diff --git a/crates/pantograph-workflow-service/src/graph/session_node_api.rs b/crates/pantograph-workflow-service/src/graph/session_node_api.rs
index aa63ed567..4f2e3ba19 100644
--- a/crates/pantograph-workflow-service/src/graph/session_node_api.rs
+++ b/crates/pantograph-workflow-service/src/graph/session_node_api.rs
@@ -40,6 +40,33 @@ impl GraphSessionStore {
request.node_id
)));
}
+ let original = state
+ .graph
+ .find_node(&request.node_id)
+ .expect("checked node");
+ if self
+ .node_registry
+ .custom_nodes
+ .get(&original.node_type)
+ .is_some()
+ || original
+ .data
+ .get(crate::custom_nodes::CUSTOM_NODE_IDENTITY_FIELD)
+ .is_some()
+ {
+ let mut candidate = original.clone();
+ merge_node_data(&mut candidate.data, request.data.clone());
+ if candidate
+ .data
+ .get(crate::custom_nodes::CUSTOM_NODE_IDENTITY_FIELD)
+ != original
+ .data
+ .get(crate::custom_nodes::CUSTOM_NODE_IDENTITY_FIELD)
+ || candidate.data.get("definition") != original.data.get("definition")
+ {
+ return Err(WorkflowServiceError::InvalidRequest("custom-node identity/definition cannot be patched; explicitly remove and recreate from the admitted catalog".into()));
+ }
+ }
state.push_undo_snapshot();
let node = state.graph.find_node_mut(&request.node_id).ok_or_else(|| {
WorkflowServiceError::InvalidRequest(format!(
@@ -110,6 +137,24 @@ impl GraphSessionStore {
let mut state = handle.lock().await;
state.touch();
let before_graph = state.graph.clone();
+ self.node_registry
+ .validate_custom_node_identity(&request.node)
+ .map_err(|error| WorkflowServiceError::InvalidRequest(error.to_string()))?;
+ if request
+ .node
+ .data
+ .get(crate::custom_nodes::CUSTOM_NODE_IDENTITY_FIELD)
+ .is_some()
+ && self
+ .node_registry
+ .custom_nodes
+ .get(&request.node.node_type)
+ .is_none()
+ {
+ return Err(WorkflowServiceError::InvalidRequest(
+ "custom-node implementation is unavailable".into(),
+ ));
+ }
state.push_undo_snapshot();
let node_id = request.node.id.clone();
state.graph.nodes.push(request.node);
diff --git a/crates/pantograph-workflow-service/src/graph/session_state.rs b/crates/pantograph-workflow-service/src/graph/session_state.rs
index d44bda6b0..f4ff2e920 100644
--- a/crates/pantograph-workflow-service/src/graph/session_state.rs
+++ b/crates/pantograph-workflow-service/src/graph/session_state.rs
@@ -20,9 +20,10 @@ use super::types::WorkflowGraph;
const DEFAULT_MAX_UNDO_SNAPSHOTS: usize = 64;
-#[derive(Debug, Clone)]
+#[derive(Clone)]
pub(super) struct GraphEditSession {
pub(super) graph: WorkflowGraph,
+ node_registry: std::sync::Arc,
workflow_id: Option,
undo_stack: Vec,
redo_stack: Vec,
@@ -31,10 +32,15 @@ pub(super) struct GraphEditSession {
}
impl GraphEditSession {
- pub(super) fn new(mut graph: WorkflowGraph, workflow_id: Option) -> Self {
+ pub(super) fn new(
+ mut graph: WorkflowGraph,
+ workflow_id: Option,
+ node_registry: std::sync::Arc,
+ ) -> Self {
graph = hydrate_embedding_emit_metadata_flags(graph);
let mut session = Self {
graph,
+ node_registry,
workflow_id: workflow_id.and_then(normalize_optional_session_text),
undo_stack: Vec::new(),
redo_stack: Vec::new(),
@@ -70,6 +76,7 @@ impl GraphEditSession {
build_graph_session_response_with_projection(
session_id,
&self.graph,
+ &self.node_registry,
None,
phase6_memory_impact_projection(self.last_memory_impact.clone()),
)
@@ -94,6 +101,7 @@ impl GraphEditSession {
build_graph_session_response_with_projection(
session_id,
&self.graph,
+ &self.node_registry,
workflow_event,
projection,
)
@@ -191,7 +199,7 @@ impl GraphEditSession {
pub(super) fn canonicalize_graph(&mut self) {
let graph = std::mem::take(&mut self.graph);
- self.graph = canonicalize_workflow_graph(graph, &NodeRegistry::new());
+ self.graph = canonicalize_workflow_graph(graph, &self.node_registry);
self.graph.refresh_derived_graph();
}
}
@@ -199,12 +207,14 @@ impl GraphEditSession {
pub(super) fn build_graph_session_response_with_projection(
session_id: &str,
graph: &WorkflowGraph,
+ registry: &NodeRegistry,
workflow_event: Option,
projection: Option,
) -> WorkflowGraphEditSessionGraphResponse {
- super::session_contract::build_graph_session_response_with_state(
+ super::session_contract::build_graph_session_response_with_registry(
session_id,
graph,
+ registry,
workflow_event,
projection.as_ref(),
)
diff --git a/crates/pantograph-workflow-service/src/workflow.rs b/crates/pantograph-workflow-service/src/workflow.rs
index 24ee52b89..cf742ae05 100644
--- a/crates/pantograph-workflow-service/src/workflow.rs
+++ b/crates/pantograph-workflow-service/src/workflow.rs
@@ -1,3 +1,4 @@
+mod custom_node_authoring_api;
#[cfg(test)]
use async_trait::async_trait;
use std::path::PathBuf;
diff --git a/crates/pantograph-workflow-service/src/workflow/custom_node_authoring_api.rs b/crates/pantograph-workflow-service/src/workflow/custom_node_authoring_api.rs
new file mode 100644
index 000000000..1992eadd9
--- /dev/null
+++ b/crates/pantograph-workflow-service/src/workflow/custom_node_authoring_api.rs
@@ -0,0 +1,112 @@
+use super::{WorkflowService, WorkflowServiceError};
+use crate::custom_nodes::{authoring::*, CUSTOM_NODE_IDENTITY_FIELD};
+use crate::{GraphEdge, GraphNode, Position, WorkflowGraph, WorkflowGraphEditSessionGraphResponse};
+use serde_json::json;
+
+impl WorkflowService {
+ /// Empty means no host registrations; transport failure is a separate outcome.
+ pub fn custom_node_authoring_catalog(&self) -> CustomNodeAuthoringCatalog {
+ CustomNodeAuthoringCatalog {
+ contract_version: 1,
+ nodes: self
+ .node_registry
+ .custom_nodes
+ .nodes()
+ .map(|node| {
+ let ports = |ports: &[pantograph_node_contracts::PortContract]| {
+ ports
+ .iter()
+ .map(|port| CustomNodeAuthoringPort {
+ id: port.id.as_str().into(),
+ label: port.label.clone(),
+ })
+ .collect()
+ };
+ CustomNodeAuthoringEntry {
+ identity: node.identity().clone(),
+ label: node.contract().label.clone(),
+ description: node.contract().description.clone(),
+ trust: CustomNodeAuthoringTrust::TrustedInProcess,
+ inputs: ports(&node.contract().inputs),
+ outputs: ports(&node.contract().outputs),
+ }
+ })
+ .collect(),
+ }
+ }
+
+ /// Resolve the user's complete selection before creating any session. Imported pins
+ /// never pass through this operation, and are never silently updated.
+ pub async fn create_custom_node_draft(
+ &self,
+ request: CustomNodeDraftRequest,
+ ) -> Result {
+ let custom = self
+ .node_registry
+ .custom_nodes
+ .get(&request.identity.node_type)
+ .ok_or_else(|| {
+ WorkflowServiceError::InvalidRequest(
+ crate::custom_nodes::CustomNodeError::Unavailable.to_string(),
+ )
+ })?;
+ if custom.identity() != &request.identity {
+ return Err(WorkflowServiceError::InvalidRequest(
+ crate::custom_nodes::CustomNodeError::IdentityMismatch.to_string(),
+ ));
+ }
+ let mut graph = WorkflowGraph {
+ nodes: vec![GraphNode {
+ id: "custom".into(),
+ node_type: request.identity.node_type.clone(),
+ position: Position { x: 320.0, y: 0.0 },
+ data: json!({"label": custom.contract().label, CUSTOM_NODE_IDENTITY_FIELD: custom.identity()}),
+ }],
+ edges: vec![],
+ derived_graph: None,
+ };
+ for (direction, ports, node_type) in [
+ ("input", &custom.contract().inputs, "text-input"),
+ ("output", &custom.contract().outputs, "text-output"),
+ ] {
+ let definition = self
+ .node_registry
+ .get_definition(node_type)
+ .ok_or_else(|| {
+ WorkflowServiceError::Internal("text authoring contract is unavailable".into())
+ })?;
+ for (index, port) in ports.iter().enumerate() {
+ let id = format!("{direction}-{index}");
+ let input = direction == "input";
+ graph.nodes.push(GraphNode {
+ id: id.clone(),
+ node_type: node_type.into(),
+ position: Position {
+ x: if input { 0.0 } else { 640.0 },
+ y: index as f64 * 160.0,
+ },
+ data: json!({"label": port.label, "text": "", "definition": definition}),
+ });
+ graph.edges.push(GraphEdge {
+ id: format!("edge-{id}"),
+ source: if input { id.clone() } else { "custom".into() },
+ source_handle: if input {
+ "text".into()
+ } else {
+ port.id.as_str().into()
+ },
+ target: if input { "custom".into() } else { id },
+ target_handle: if input {
+ port.id.as_str().into()
+ } else {
+ "text".into()
+ },
+ });
+ }
+ }
+ let session = self.graph_session_store.create_session(graph, None).await;
+ self.graph_session_store
+ .get_session_graph(&session.session_id)
+ .await
+ }
+}
diff --git a/crates/pantograph-workflow-service/src/workflow/service_config.rs b/crates/pantograph-workflow-service/src/workflow/service_config.rs
index 919c487c1..01a5c7a4e 100644
--- a/crates/pantograph-workflow-service/src/workflow/service_config.rs
+++ b/crates/pantograph-workflow-service/src/workflow/service_config.rs
@@ -92,7 +92,9 @@ impl WorkflowService {
runtime_dispatch_assignment_repository: Arc::new(Mutex::new(
InMemoryWorkflowRuntimeDispatchAssignmentRepository::new(),
)),
- graph_session_store: Arc::new(GraphSessionStore::new()),
+ graph_session_store: Arc::new(
+ GraphSessionStore::new().with_node_registry(node_registry.clone()),
+ ),
artifact_writer: None,
artifact_format_settings: Arc::new(Mutex::new(ArtifactFormatSettings::default())),
artifact_format_settings_path: None,
@@ -190,7 +192,8 @@ impl WorkflowService {
provider: Arc,
) -> Self {
self.graph_session_store = Arc::new(
- GraphSessionStore::with_inference_interface_facts_provider(provider),
+ GraphSessionStore::with_inference_interface_facts_provider(provider)
+ .with_node_registry(self.node_registry.clone()),
);
self
}
@@ -201,11 +204,14 @@ impl WorkflowService {
inference_provider: Arc,
provider: SharedDependencyEnvironmentProvider,
) -> Self {
- self.graph_session_store = Arc::new(GraphSessionStore::with_timeout_and_providers(
- std::time::Duration::from_secs(5 * 60),
- inference_provider,
- provider.clone(),
- ));
+ self.graph_session_store = Arc::new(
+ GraphSessionStore::with_timeout_and_providers(
+ std::time::Duration::from_secs(5 * 60),
+ inference_provider,
+ provider.clone(),
+ )
+ .with_node_registry(self.node_registry.clone()),
+ );
self.dependency_readiness_provider = Arc::new(DependencyEnvironmentService::new(provider));
self
}
diff --git a/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs b/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs
index 6e16c03d5..9226d62a0 100644
--- a/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs
+++ b/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs
@@ -362,3 +362,272 @@ async fn custom_nodes_callback_failures_are_terminal_without_publishing_outputs(
.is_err());
}
}
+
+async fn authored_draft(service: &WorkflowService) -> crate::WorkflowGraphEditSessionGraphResponse {
+ let identity = service.custom_node_authoring_catalog().nodes[0]
+ .identity
+ .clone();
+ service
+ .create_custom_node_draft(crate::custom_nodes::authoring::CustomNodeDraftRequest {
+ identity,
+ })
+ .await
+ .unwrap()
+}
+
+#[tokio::test]
+async fn custom_node_authoring_registry_survives_provider_builders_and_undo_redo() {
+ for mode in 0..3 {
+ let service = WorkflowService::with_trusted_custom_nodes(catalog(Arc::new(Executor::new(
+ Behavior::Uppercase,
+ ))));
+ let service = match mode {
+ 1 => service.with_inference_interface_facts_provider(Arc::new(crate::graph::UnavailableInferenceInterfaceFactsProvider)),
+ 2 => service.with_graph_session_fact_providers(Arc::new(crate::graph::UnavailableInferenceInterfaceFactsProvider), Arc::new(pantograph_dependency_environment_service::NotImplementedDependencyEnvironmentProvider)),
+ _ => service,
+ };
+ let draft = authored_draft(&service).await;
+ assert!(
+ draft
+ .graph_diagnostics
+ .iter()
+ .all(|d| !d.blocking_submission),
+ "{:?}",
+ draft.graph_diagnostics
+ );
+ let pin = draft.graph.nodes[0].data[CUSTOM_NODE_IDENTITY_FIELD].clone();
+ let updated = service
+ .workflow_graph_update_node_data(crate::WorkflowGraphUpdateNodeDataRequest {
+ session_id: draft.session_id.clone(),
+ node_id: "input-0".into(),
+ data: serde_json::json!({"text": "authored"}),
+ })
+ .await
+ .unwrap();
+ assert_eq!(
+ updated.graph.find_node("input-0").unwrap().data["text"],
+ "authored"
+ );
+ let request = crate::WorkflowGraphEditSessionGraphRequest {
+ session_id: draft.session_id,
+ };
+ let undo = service.workflow_graph_undo(request.clone()).await.unwrap();
+ let redo = service.workflow_graph_redo(request).await.unwrap();
+ for snapshot in [undo, redo] {
+ assert_eq!(
+ snapshot.graph.find_node("custom").unwrap().data[CUSTOM_NODE_IDENTITY_FIELD],
+ pin
+ );
+ assert!(snapshot
+ .graph_diagnostics
+ .iter()
+ .all(|d| !d.blocking_submission));
+ }
+ }
+}
+
+#[tokio::test]
+async fn custom_node_authoring_refuses_stale_selection_and_identity_patches_without_mutation() {
+ let service = WorkflowService::with_trusted_custom_nodes(catalog(Arc::new(Executor::new(
+ Behavior::Uppercase,
+ ))));
+ let entry = service.custom_node_authoring_catalog().nodes[0].clone();
+ let mut stale = entry.identity.clone();
+ stale.environment_digest = crate::custom_nodes::tests::digest("changed environment");
+ let error = service
+ .create_custom_node_draft(crate::custom_nodes::authoring::CustomNodeDraftRequest {
+ identity: stale.clone(),
+ })
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("identity does not match"));
+ assert!(
+ serde_json::from_value::(
+ serde_json::json!({"identity": entry.identity, "definition": {}})
+ )
+ .is_err()
+ );
+ let draft = authored_draft(&service).await;
+ for patch in [
+ serde_json::json!({CUSTOM_NODE_IDENTITY_FIELD: stale}),
+ serde_json::json!({"definition": {}}),
+ serde_json::json!({CUSTOM_NODE_IDENTITY_FIELD: null}),
+ ] {
+ let error = service
+ .workflow_graph_update_node_data(crate::WorkflowGraphUpdateNodeDataRequest {
+ session_id: draft.session_id.clone(),
+ node_id: "custom".into(),
+ data: patch,
+ })
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("cannot be patched"));
+ let after = service
+ .workflow_graph_get_edit_session_graph(crate::WorkflowGraphEditSessionGraphRequest {
+ session_id: draft.session_id.clone(),
+ })
+ .await
+ .unwrap();
+ assert_eq!(after.graph_revision, draft.graph_revision);
+ assert_eq!(after.graph, draft.graph);
+ }
+ let mut unpinned = draft.graph.find_node("custom").unwrap().clone();
+ unpinned.id = "bad".into();
+ unpinned.data = serde_json::json!({});
+ assert!(service
+ .workflow_graph_add_node(crate::WorkflowGraphAddNodeRequest {
+ session_id: draft.session_id,
+ node: unpinned
+ })
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("identity does not match"));
+}
+
+#[tokio::test]
+async fn custom_node_authoring_candidates_and_commit_share_exact_registry() {
+ let service = WorkflowService::with_trusted_custom_nodes(catalog(Arc::new(Executor::new(
+ Behavior::Uppercase,
+ ))));
+ let draft = authored_draft(&service).await;
+ let removed = service
+ .workflow_graph_remove_edge(crate::WorkflowGraphRemoveEdgeRequest {
+ session_id: draft.session_id.clone(),
+ edge_id: "edge-input-0".into(),
+ })
+ .await
+ .unwrap();
+ let source = crate::ConnectionAnchor {
+ node_id: "input-0".into(),
+ port_id: "text".into(),
+ };
+ let candidates = service
+ .workflow_graph_get_connection_candidates(
+ crate::WorkflowGraphGetConnectionCandidatesRequest {
+ session_id: draft.session_id.clone(),
+ source_anchor: source.clone(),
+ graph_revision: Some(removed.graph_revision.clone()),
+ },
+ )
+ .await
+ .unwrap();
+ assert!(candidates
+ .compatible_nodes
+ .iter()
+ .any(|node| node.node_id == "custom"));
+ assert!(!candidates
+ .insertable_node_types
+ .iter()
+ .any(|node| node.node_type == "custom-uppercase"));
+ let rejected = service
+ .workflow_graph_preview_node_insert_on_edge(
+ crate::WorkflowGraphPreviewNodeInsertOnEdgeRequest {
+ session_id: draft.session_id.clone(),
+ edge_id: "edge-output-0".into(),
+ node_type: "custom-uppercase".into(),
+ graph_revision: removed.graph_revision.clone(),
+ },
+ )
+ .await
+ .unwrap();
+ assert!(!rejected.accepted);
+ assert!(rejected
+ .rejection
+ .unwrap()
+ .message
+ .contains("insertion by name is unsupported"));
+ let committed = service
+ .workflow_graph_connect(crate::WorkflowGraphConnectRequest {
+ session_id: draft.session_id.clone(),
+ source_anchor: source,
+ target_anchor: crate::ConnectionAnchor {
+ node_id: "custom".into(),
+ port_id: "input".into(),
+ },
+ graph_revision: removed.graph_revision,
+ })
+ .await
+ .unwrap();
+ assert!(committed.accepted);
+ let snapshot = service
+ .workflow_graph_get_edit_session_graph(crate::WorkflowGraphEditSessionGraphRequest {
+ session_id: draft.session_id,
+ })
+ .await
+ .unwrap();
+ assert!(snapshot
+ .graph_diagnostics
+ .iter()
+ .all(|d| !d.blocking_submission));
+}
+
+#[tokio::test]
+async fn custom_node_authoring_saved_reopen_preserves_pin_and_scheduler_execution() {
+ let root = tempfile::tempdir().unwrap();
+ let catalog = catalog(Arc::new(Executor::new(Behavior::Uppercase)));
+ let service = WorkflowService::with_trusted_custom_nodes(catalog.clone());
+ let draft = authored_draft(&service).await;
+ let store = FileSystemWorkflowGraphStore::new(root.path());
+ let saved = service
+ .workflow_graph_save(
+ &store,
+ crate::WorkflowGraphSaveRequest {
+ name: "custom-workflow".into(),
+ graph: draft.graph.clone(),
+ },
+ )
+ .unwrap();
+ let reopened = WorkflowService::with_trusted_custom_nodes(catalog);
+ let graph = reopened
+ .workflow_graph_load(
+ &store,
+ crate::WorkflowGraphLoadRequest {
+ path: saved.path.clone(),
+ },
+ )
+ .unwrap()
+ .graph;
+ assert_eq!(
+ graph.find_node("custom").unwrap().data[CUSTOM_NODE_IDENTITY_FIELD],
+ draft.graph.find_node("custom").unwrap().data[CUSTOM_NODE_IDENTITY_FIELD]
+ );
+ for (service, blocking) in [(&reopened, false), (&WorkflowService::new(), true)] {
+ let edit = service
+ .workflow_graph_create_edit_session(crate::WorkflowGraphEditSessionCreateRequest {
+ graph: graph.clone(),
+ workflow_id: Some(saved.path.clone()),
+ })
+ .await
+ .unwrap();
+ let snapshot = service
+ .workflow_graph_get_edit_session_graph(crate::WorkflowGraphEditSessionGraphRequest {
+ session_id: edit.session_id,
+ })
+ .await
+ .unwrap();
+ assert_eq!(
+ snapshot.graph.find_node("custom").unwrap().data,
+ graph.find_node("custom").unwrap().data
+ );
+ assert_eq!(
+ snapshot
+ .graph_diagnostics
+ .iter()
+ .any(|d| d.blocking_submission),
+ blocking
+ );
+ }
+ let host = SavedGraphHost {
+ root: root.path().into(),
+ };
+ let session_id = session(&reopened, &host).await;
+ let mut request = run_request(&session_id);
+ request.inputs[0].node_id = "input-0".into();
+ request.output_targets.as_mut().unwrap()[0].node_id = "output-0".into();
+ let result = reopened
+ .run_workflow_execution_session(&host, request)
+ .await
+ .unwrap();
+ assert_eq!(result.outputs[0].value, "MIXED CASE");
+}
diff --git a/docs/custom-nodes.md b/docs/custom-nodes.md
index 9ed1bcb6e..543552b93 100644
--- a/docs/custom-nodes.md
+++ b/docs/custom-nodes.md
@@ -82,13 +82,47 @@ adds the `custom_node` non-runtime template; identity adapter version 1 owns thi
bounded contract. Coordinated Rust producers/consumers are updated together;
older implementations cannot execute that template.
-NodeLab authoring, Local Agent and runtime publishing remain unavailable.
-Graph edit-session authoring and connection APIs still use built-in discovery;
-they are not custom-node authoring surfaces. Before exposing custom nodes there,
-NodeLab must install host-authorized registrations and exact graph pins, use the
-same registry for edit validation/connection previews, and expose trust, version
-mismatch and unavailable implementation outcomes. Publishing arbitrary code
-needs its own artifact verification, isolation and lifecycle contract.
+## NodeLab graph authoring
+
+NodeLab authors workflows from implementations already admitted by its host. It
+reads `custom_node_authoring_catalog` (wire version 1), a narrow projection of
+canonical labels, required string port IDs and complete immutable identities.
+The projection's `trusted_in_process` profile carries the trust obligations above;
+it is not an installation or execution permission. Default desktop composition
+has an empty catalog and says so. Host maintainers register implementations in
+Rust before service construction; browser clients cannot register or load code.
+The legacy generic palette remains built-in-only.
+
+`create_custom_node_draft` requires the complete selected identity. The service
+resolves exact equality before creating a session, then builds one text source
+per required input, the pinned custom node, and one text sink per output. Custom
+nodes never receive `node.data.definition`. NodeLab owns a separate edit session,
+allows text source edits, displays backend contract diagnostics, and saves/reopens
+through existing graph persistence. It provides no execution button; saved graphs
+use scheduler sessions. Input text is authored configuration, not tool authority.
+
+The same admitted registry reaches edit-session canonicalization, diagnostics,
+connection candidates and commits, including provider-builder construction. New
+custom additions require exact pins. General custom data patches cannot change
+identity or definitions. Imported/reopened missing or stale pins remain unchanged
+and produce blocking diagnostics; explicit new authoring is the recovery path.
+Name-only insert-and-connect/insert-on-edge APIs cannot express a selected complete
+identity, so custom insertion there is explicitly unsupported and omitted from
+creation suggestions. Adding a pinned node and connecting existing endpoints is
+supported.
+
+Failed create/reopen preserves the current draft. Replacement and page teardown
+close owned sessions, including late creation completion. Transport failures during
+close retain exact session IDs in the app-scoped NodeLab service. Pending cleanup
+is visible on the next NodeLab mount, blocks new creation/reopening and has an
+explicit retry action. No automatic graph-session reap is assumed. A process or
+host restart ends these ephemeral sessions; saved files retain their exact pins.
+
+The desktop commands are `get_custom_node_authoring_catalog`,
+`create_custom_node_draft` and `get_node_lab_draft`; mutations/persistence reuse
+existing commands. Local Agent and runtime publishing remain unavailable or
+unsupported. Arbitrary packages still need independently admitted artifact
+verification, isolation and lifecycle contracts.
A scheduler-managed tool loop must consume this exact admission and invocation
identity, plus its own typed tool authority, bounded iterations, cancellation,
diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz
new file mode 100644
index 0000000000000000000000000000000000000000..ec6e6c01c5b6d92e7d5f711e3f8f19252feaee63
GIT binary patch
literal 441
zcmV;q0Y?5GiwFP!0000216@+hZWA#OzVB0v_8gpSNQ=~CLE?rGlmkK>9D9;X*ME5I
zRZ_%-SAZi=*C%0|Ra&XO`TLpqX6%Uy!gQ@RYJhDeXDRGq7c-%ang
zq1*lO-yax;4R{ebWO(AysxD)&m|G!psA1%0Fwv)48n%C+1WXUuU`t$Q~9^t0(A9`Uq|I$JPOuC18OT#7~V=ePt`Bl9#Ul8yz-%1(glPFEQPl5xPbQ`zcft~
j%o+M19VfigHB+k@Vu2r3JKB*bx?kOYkqj1t@9th3OC
literal 0
HcmV?d00001
diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/native-unavailable.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/native-unavailable.log.gz
new file mode 100644
index 0000000000000000000000000000000000000000..3080c4fbb6a61d170f1146064ebdb28df58b9529
GIT binary patch
literal 2190
zcmV;92yyoxiwFP!000021FczYZ``;M{_bC)Z)wq!yxJFeZ6Jr{++Gq~8XqnOihhzQ
zidr$2NQI=lyZ-eZzGy9xvYVU>1X^X`laFcp0ZuEM^x$0ii@bC4T&@%
z;w&n9+LTdA1;L7o;~5;f=50qC%`J&2NshT7(LK~>eD9moZ#i)1mSHhiZQu9Kyw@R10P
zd3V&*ZL(TTsa3CfL1e6uyw@D7WR=cy^olm~932w+GjF(ZFcXL8{!;p=Mk9;m}1vAB~7@vYgTcVr7nN1pjO%J>MuR$)QD^sl$~|1)W%b
zTCdlkXe-kzj^e0D+v9s}IDIg7L5X{Y4NS(PwXayaR0-Z%&0e^V{GQkb@bgPMT{%OF
z)0qsPD*qx$Js1#!r$F*tMMI@-T_;Q}HC+w|Q$K;aaxf@p$ib%G!xzQdCNGg9inhk3
zELlxC_LMQc*Q8Cd_JV{tlPc!aWg(l%d`YDe_Our(Tno;4tR9W!V4L58O{Ht%!jED}
zcSI_8$nPP_j!xg!FACfun>7g1#gtUZoE)Ikbk>3rq__tV!KfJ^d>QsN+RM1Xu{;`>
zUS0SyI<5#)6g7FTDp$;57L}mH;>jvBp@S^bQdibV|C9mqrh(}LO>QtffOL%mjf}f{
z8mnVRr0I&F?o~|zS_KzYM!KDcZ*m~GS6qxCJP3!74w2Tg?sP;bN}RE?@Gd*V7%6RC
zdicH@v2gQGKjK}u@sY#ErRkk}CwLswC3K{X9$
zw+*-v03q_4!&blT$+e&ElA#=xsB;bkc&od*pgR}|qoy9Uj#8cOY3uxqa66m4If1Jk
zq_wjXW|M5gfQ;H|lm}Aik`kONIg0kvNjG^63?bnP+{aDmTGGZP=|D!N2dd}M(hV-p
znO-zHea_l3TyoGSkfavhgLV@zOAj%E5^j+~^TKQB5@BarIzu@Dc^YhGitmX7z17Sl
zSEn)V6(+OU%v3bUfxZ7=bi6p~fwiQ=N}el@>ouH)em+f6!3vLp(^b0z8kqy2@n@
z1y&C2I2+qxZft_n^T!Zi;XW?WNlUI2CN57D**NOe78
z8)O|oYR@P}(oCNqxSGaG67OlL+&b$;w}t7R?|RT;f7b;i17BXxmRu%4Nertn&Y
z<-@4L{KWyN<{t0o>RS)%NPMu1)N1QvlB8`3o9ouyQRV_*-#eb4LbSs#k&R)`les8g
z9C6BCP@m@z`l9$e8A0vU&-C2WwZjz7<^0`+wC2?f5*EJL^etkxb(dkA7z5^2^WrN&
zK`zyh&)}u#)QASFw!Q$3ak3z&jY-FG?cS?A*wI6=>W4+RHEEn_=MkIU#bb-#fnr*Z0WFH3EHXH{Bjrl#cEVY_3|o8{|Y9>
zaSVSbd9UCX3<~&_AmC+8tC%oo*V>Gmx9>lF{3VAX$UooYzyJ0=zrOkW>&N#mpLycT
z58Euy)^X478LYuEB%(jWJJ$c8fZPeljez`r3ixpI<@QgQ``uRk{_gg}>wOzZd8LC!
z)DP+XZf3~;KR*8PdS@qcUhA$E_Jg{=oBhjwzWi}>bDRHP{0@b^*7=ddKV!w|jKjaT
z8ldiXfSs(OI|EJtTzfrxEMi1@36J#l@^ds2`R>!Fn}2J;xBvWCe*NpGckjQvO~;aA
zh*lxt1KV#-3}yk8L~l+%{romsCKro0XCVBxChlQFf59V=I67bj#yh=!_f!}}g$bfx
zYfh+p7wwUZ7*_zsJu4F@%Z$F0grviF5hDbaV1V<2M3jMdjfQs(;|dOV9TGt(so<<7
z`rQbIv021g7y|ksS0TzG{XLU@qydK0cUgn_?~+EX-IAz8hv37<&$m|*t)e6EqZ&Po
zt<)B}xcn3;2o4-FU#|Izz&*`-GH9??^G?D6Do-QO4mCUPewuu5$IVwIUwL5ySAiT^
QCrNVlUq8znCg~dh0L8mLwEzGB
literal 0
HcmV?d00001
diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json
new file mode 100644
index 000000000..5d259bf2c
--- /dev/null
+++ b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json
@@ -0,0 +1,54 @@
+{
+ "base": "285c48989a95caef1bcaac92eac9ff3b1be0a9ad",
+ "environment": {
+ "os": "Linux x86_64",
+ "rust": "1.92.0",
+ "node": "24.12.0",
+ "chromium": "Chromium 151.0.7922.173 built on Debian GNU/Linux 13 (trixie)",
+ "mode": "headless; disposable profile/store; GPU disabled; container verification no-sandbox only"
+ },
+ "claims": {
+ "browser_user_workflow": "passed: actual NodeLab -> loopback -> Rust service -> filesystem create/edit/save/reopen; keyboard/pointer and focus; missing implementation blocking; exact pin retained",
+ "service_tests": "1068 library +65 integration passed, zero failures/ignored",
+ "frontend_tests": "740 passed, zero failures/ignored; 10 focused decoding/controller/presenter tests included",
+ "native_tauri": "unavailable: cargo check --locked -p pantograph --bin pantograph --no-default-features fails at glib-sys: missing glib-2.0.pc; pkg-config also lacks GTK/WebKit; no native startup claim",
+ "not_claimed": [
+ "installed custom nodes in default desktop",
+ "arbitrary code loading",
+ "trained model T3",
+ "GPU",
+ "inference-backed/effectful tool loops",
+ "release qualification"
+ ]
+ },
+ "source_sha256": {
+ "crates/pantograph-workflow-service/examples/nodelab_host.rs": "1c9a33092e0bac9560247b8c1e158bc0405527cad7b0a280d79dc61334eed8c8",
+ "crates/pantograph-workflow-service/src/custom_nodes.rs": "1d413febbad16689c864e7945191c7bb83079530ece13fda73541178bd86f774",
+ "crates/pantograph-workflow-service/src/custom_nodes/authoring.rs": "ab2e37ca4a68b846e8f23897891eea064730a1376c8597eb5ce00e1bf5f909ed",
+ "crates/pantograph-workflow-service/src/graph/connection_insert.rs": "881afe99675e681a2bd1208f69e55f82afa881adec9ead9a6863089dd3a6f2af",
+ "crates/pantograph-workflow-service/src/graph/connection_intent.rs": "d5832d05d88dd0d2dc10cb367038a0a6d5973e1275fe19e888fb411c13057199",
+ "crates/pantograph-workflow-service/src/graph/session.rs": "e9248df745093b0ff078691a04036017fed894bdd073d922b0f628920a1efcac",
+ "crates/pantograph-workflow-service/src/graph/session_connection_api.rs": "f29f061031b4c70f829cfbd603108857ed7d093245ae3cdeb8d63797b9c5fefe",
+ "crates/pantograph-workflow-service/src/graph/session_contract.rs": "844733c80c5f5a372844637a104e901b9cd46433e1ef73662ff314f4b8e134b0",
+ "crates/pantograph-workflow-service/src/graph/session_node_api.rs": "61505593be43e6bc58e9c9c8f1b991cfd75949a55da8351278ca45a25015ecd3",
+ "crates/pantograph-workflow-service/src/graph/session_state.rs": "c19150bbf4b61dd506ad6bb618593c40f59a8fde43f554025eb940e8c75b1d17",
+ "crates/pantograph-workflow-service/src/workflow.rs": "47e42061802abc91ebe6b3babf8c578116d2e0fecf51d86fa53148cbed613886",
+ "crates/pantograph-workflow-service/src/workflow/custom_node_authoring_api.rs": "5bf54f01b2db4ec2ffc21b6af41291c695ff91a3b274deb80838c32020b9941d",
+ "crates/pantograph-workflow-service/src/workflow/service_config.rs": "257edc58a50b37e8dee265b971e91084b0f77b0ce4031f8d105b604f33cea513",
+ "crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs": "aa080d798774771aa419db01d83cd26401684ab06333770a74e209c601e5952f",
+ "scripts/README.md": "03bed3d3d157a4d7e4a9f2a291f4a2ab37efa2e81f87c83e3d5710a38220119a",
+ "scripts/check-nodelab-browser.mjs": "30a619d382ddb242c19b50c51ec97fda8a6c20973fe51f94c7c0fcf5658d24d4",
+ "src-tauri/src/app_setup.rs": "b14c996956638c5a708156aaf8f110ce6f1fa3a175f6b03bb9ff0284bd392a50",
+ "src-tauri/src/workflow/commands.rs": "24834569833ec6509300b9c8c795b9eb8be233f0c6d9c37c5d67f82b63d24b6c",
+ "src/components/workbench/NodeLabPage.svelte": "b9206ed693d2a18f065c547752622cbb48a96a3599d1867d166e45f39fc7424f",
+ "src/components/workbench/nodeLabController.ts": "3b3806dcf0aedf191fd7ad91ce3ac746a08c6962b1c0b292f54d9ba6e9145716",
+ "src/components/workbench/nodeLabPresenters.test.ts": "5debffcdba6ddc86d4b4093dceb08dbf691b7659b73a483abd8d991616faa02e",
+ "src/components/workbench/nodeLabPresenters.ts": "4792bedc18bf6b55173a892e0f15bc7900b4c36fd84bfc508da3dac9c0bbb075",
+ "src/services/workflow/customNodeAuthoring.test.ts": "73d776ffb40ddcafcfb0e69f7434f98aa19bd037484a36ccdef7d9eff290d250",
+ "src/services/workflow/customNodeAuthoring.ts": "f4a11c2fa37816819db79176b2af584199c946a5e3c533482cc595362092dc08"
+ },
+ "evidence_sha256": {
+ "browser.log.gz": "39a0549788107cc92c66b172a0810f2f81b40de30458af58e660a67fa1b45287",
+ "native-unavailable.log.gz": "f72bc764c185d785964c3ac95e9a0d6f24a1d999aefbd349dc143eed28d261b3"
+ }
+}
diff --git a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md
index 7b4348f1e..e6a6f09de 100644
--- a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md
+++ b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md
@@ -813,3 +813,36 @@ Accepted stop-string/PR60 and all seed/precision/repair/paused histories remain
separate. Controlled package/readiness/dispatch/session facts and untrained CPU
models do not qualify live Pumas, pretrained quality, GPU, desktop or native
batching/throughput. Full plan acceptance and parent publication remain separate.
+
+## 2026-10-22 — NodeLab trusted-node authoring
+
+Continued the admitted CN-02 slice on main `285c4898` after CN-01/README merges.
+The [consumer contract](../../custom-nodes.md#nodelab-graph-authoring) owns the
+read-only versioned catalog, exact-selection server-built drafts, shared edit
+registry, pin-preserving mutations and saved/reopened behavior. Default desktop
+has no custom registrations; the separate real Rust qualification host supplies
+one compiled pure transform. No code loader, execution UI or tool grant is added.
+Independent GPT-6.1 Sol High review accepted after repairing failed replacement
+loss, explicit-null diagnostic decoding and close-failure ownership/recovery.
+
+[Qualification](evidence/nodelab-authoring/qualification.json) retains source
+bindings, actual Chromium→Rust→filesystem user-workflow evidence and the native
+failure. Service tests pass 1,068 library +65 integration, zero failures/ignored;
+740 frontend tests pass including ten focused parser/controller/presenter cases.
+Formatting, typecheck, production frontend build, lint, critical/a11y gates,
+scheduler-only guard and traceability tests pass. All-target package Clippy
+retains only the existing inference dead-field warning. Initial new fixtures used
+an unexported provider and a nonexistent response field; corrected against the
+actual owning APIs, preserving the assertions. The first browser Enter simulation
+omitted character text and did not activate the button; proper CDP key events
+then passed the same workflow assertions, with no pointer fallback for that gate.
+
+Native `cargo check --locked -p pantograph --bin pantograph --no-default-features`
+is unavailable at glib-sys because this environment lacks `glib-2.0.pc`; GTK and
+WebKit development packages are also absent. No native/desktop startup claim or
+persistent access change is made. PR92 `afe33029` remains open with its unique
+trained-text source/evidence and missing T3 gate unchanged. CN-03 PR94 integrates
+first by parent instruction; CN-02 must then reconcile current main, preserve its
+registry/loop validations, rerun combined affected tests and pass exact-head CI
+before merge. The local/remote feature branch and worktree are retained pending
+that disposition, with source and evidence preserved.
diff --git a/docs/plans/domain-architecture-and-multimodal/issues.md b/docs/plans/domain-architecture-and-multimodal/issues.md
index 78a0af760..5b5a43ac5 100644
--- a/docs/plans/domain-architecture-and-multimodal/issues.md
+++ b/docs/plans/domain-architecture-and-multimodal/issues.md
@@ -76,5 +76,5 @@ authorize the historical resident-accounting next-slice text.
| ID | Issue | Owner | Disposition / next evidence |
| --- | --- | --- | --- |
| CN-01 | Palette/callback registration does not establish canonical executable admission or implementation/environment identity. | Custom-node lead | Closed for the bounded trusted string adapter after independent GPT-6.1 Sol High review and 1,129 passing service tests. Exact admission, saved/reopened scheduler execution, cancellation/commit arbitration and inspection share the immutable catalog. Arbitrary executable loading remains outside this contract. |
-| CN-02 | NodeLab authoring, Local Agent and runtime publishing remain unavailable. | NodeLab follow-on | Deferred. Consume the [custom-node contract](../../custom-nodes.md): host-authorized registrations and exact graph pins, same registry for edit-session validation/connection previews, trust/version/unavailable outcomes. Untrusted package publishing additionally needs independently admitted artifact verification and isolation. A palette entry cannot enable execution. |
-| CN-03 | Scheduler-managed tool-loop exposure needs a safe node invocation contract and explicit tool authority. | Tool-loop follow-on | Deferred. Depend on CN-01 exact identity/admission and scheduler-owned run/attempt/cancellation/result commitment; separately admit typed tool capabilities, bounded iteration and side-effect/idempotency behavior. The string adapter grants no tool capability. Do not restore the retired static loop. |
+| CN-02 | NodeLab graph authoring requires the same admission identity and registry as execution. | NodeLab lead | Implemented, verifying: service-owned exact-selection draft creation, NodeLab source editing/save/reopen, shared edit registry and blocking stale/unavailable diagnostics. Default desktop is honestly empty; nonempty host-composed browser flow is qualified separately. Local Agent/code publishing, generic name-only custom insertion and arbitrary loading remain unsupported. See [contract](../../custom-nodes.md#nodelab-graph-authoring). |
+| CN-03 | Scheduler-managed tool-loop exposure needs a safe node invocation contract and explicit tool authority. | Tool-loop follow-on | Separate worker task `01a1615c-5be4-74a5-bf4c-c3f3b3136000`. Depend on CN-01 exact identity/admission and scheduler-owned run/attempt/cancellation/result commitment; separately admit typed tool capabilities, bounded iteration and side-effect/idempotency behavior. The string adapter grants no tool capability. Do not restore the retired static loop. |
diff --git a/docs/plans/domain-architecture-and-multimodal/plan.md b/docs/plans/domain-architecture-and-multimodal/plan.md
index fdd0a90e6..2a0b0c92a 100644
--- a/docs/plans/domain-architecture-and-multimodal/plan.md
+++ b/docs/plans/domain-architecture-and-multimodal/plan.md
@@ -298,6 +298,64 @@ Development Proportionality, Commit, Documentation, Architecture/Code Design,
Contracts, Security, Concurrency/Resilience and Library/Rust API/Async guidance.
The rest of this plan retains its historical standards baseline.
+## Admitted NodeLab/edit-session slice (2026-10-22)
+
+**Admission:** `continue` this Active plan under the delegated unfinished-path
+instruction. CN-01 is integrated in `2e517f58`, README PR79 in current main
+`285c4898`. Independent GPT-6.1 Sol High design review admits a bounded NodeLab
+graph-authoring surface before implementation. This does not author or install
+executable code. PR92's unique trained-text source/evidence and missing T3 gate
+remain separate and protected; CN-03 has its own worker and integration gate.
+
+**Outcome and contract:** NodeLab reads a versioned service-owned projection of
+host-admitted custom definitions and complete identities. The default desktop
+catalog is honestly empty; a composed host supplies nonempty registrations.
+Selecting an admitted node creates a separate edit-session draft with one text
+source per required input and one text sink per output, derived from host ports.
+Users edit source values, inspect backend diagnostics, save and reopen through
+existing graph APIs. Saved pins are never silently refreshed. Missing/stale
+implementations remain visible and blocking; recovery requires explicit new
+authoring. No direct edit-session execution, Local Agent, runtime publishing,
+arbitrary loader, new capability grant or static tool loop is enabled.
+
+The draft is constructed by the service from the exact selected identity, not
+from a client-supplied custom definition. The same immutable registry must reach
+graph-session construction, provider
+builders, canonicalization, response diagnostics and connection candidate/commit
+validation. Add requires the exact selected identity; custom data patches cannot
+change identity or add dynamic definitions. Imported invalid graphs remain
+inspectable without healing. Name-only custom insert-on-edge/insert-and-connect
+is explicitly unsupported; use pinned add plus explicit connections. The
+legacy generic palette stays built-in-only. CN-03 consumes saved graph pins and
+CN-01 scheduler invocation, not NodeLab state or a second execution route.
+
+**Composition and write set:** registry/catalog owns admitted facts; graph
+sessions own authored state, undo and validation; NodeLab owns local interaction
+and observes service responses; existing store owns persistence. Stable
+interfaces are the versioned read projection and existing pinned graph/mutation
+contracts. This avoids duplicating admission or execution in the frontend.
+Change service `custom_nodes`, `graph`, graph API/config and related tests;
+NodeLab presenters/controller/page and narrow workflow transport projections;
+desktop command wiring; a bounded real-host example/browser probe and affected
+documentation. No scheduler-loop core, text runtime/readiness, root README or
+PR92 edits. Branch `feat/nodelab-trusted-catalog`, worktree
+`/workspace/pantograph-nodelab`, base `285c4898`, target `main`. Preserve source,
+branches and evidence; parent coordinates serial integration if CN-03 intersects.
+
+**Acceptance:** focused admission/edit-session refusal tests, real save/reopen
+and connection candidate/commit tests, provider-builder registry retention,
+frontend decoder/lifecycle tests, and real-browser pointer/keyboard draft →
+edit → save → reopen interaction through an injected Rust host. Browser evidence
+does not qualify native Tauri startup. Native compile/smoke is attempted only
+where available and its actual limits recorded. Required CI, independent source
+review and current-main recheck precede merge under the later cleanup authority;
+no gate waiver, evidence-weakening rerun or persistent access expansion. Status
+`Verifying`; next slice complete independent review, current-main/CI gates and
+serial integration. Failed replacement preserves the current draft; failed close
+transfers exact session IDs to an app-scoped, user-retryable cleanup owner. Standards baseline `0fc92a2`
+continues, adding routed Frontend, TypeScript/Async, Accessibility, IPC,
+Persistence, protocol/evolution and GUI verification detail to the CN-01 routes.
+
## Standards And Plan Authority
Standards source:
diff --git a/scripts/README.md b/scripts/README.md
index 968852740..15d8c1291 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -257,3 +257,20 @@ it preserves the existing expression-evidence policy rather than proving its
runtime value. Native-button rules and reviewed-ignore rules are unchanged.
Runtime accessibility and dynamically empty/hidden content still require UI
validation.
+
+## NodeLab authoring browser qualification
+
+Build `cargo build --locked -p pantograph-workflow-service --example nodelab_host`,
+then run `NODELAB_HOST_BINARY= node
+scripts/check-nodelab-browser.mjs`. The isolated stdio Rust host registers one
+compiled pure transform and owns a temporary workflow store; the verifier exposes
+only a temporary loopback transport. It exercises the actual NodeLab component,
+strict catalog decoding, keyboard focus/creation, pointer edits, filesystem
+save/reopen, pin retention, session replacement cleanup and missing-implementation
+blocking. A second real empty host proves the empty catalog and stale reopen;
+a deliberately failed transport proves the distinct unavailable state. Browser
+arguments select headless Chromium, disposable profile, GPU disabled and the
+verification-only no-sandbox mode required by this container. They do not alter
+normal startup. This is browser/host evidence, not native Tauri, package, model,
+GPU or scheduler tool-loop qualification. EOF and verifier teardown release the
+host/store/browser resources. See [custom-node authoring](../docs/custom-nodes.md#nodelab-graph-authoring).
diff --git a/scripts/check-nodelab-browser.mjs b/scripts/check-nodelab-browser.mjs
new file mode 100644
index 000000000..76cd26290
--- /dev/null
+++ b/scripts/check-nodelab-browser.mjs
@@ -0,0 +1,172 @@
+/* global document, getComputedStyle */
+// Real NodeLab component -> loopback transport -> isolated Rust service -> filesystem.
+// No Tauri startup, installed node, model or GPU claim. Build nodelab_host first.
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { createServer } from 'node:http';
+import { createInterface } from 'node:readline';
+import { mkdtemp, readFile, writeFile, symlink, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { build } from 'vite';
+import { svelte } from '@sveltejs/vite-plugin-svelte';
+import { evaluateBrowserFunction } from './cdp-browser-function.mjs';
+
+const repo = fileURLToPath(new URL('../', import.meta.url));
+const fixture = await mkdtemp(path.join(tmpdir(), 'pantograph-nodelab-browser-'));
+const hostBinary = process.env.NODELAB_HOST_BINARY;
+assert.ok(hostBinary, 'NODELAB_HOST_BINARY must name the built nodelab_host example');
+const hosts = [];
+const servers = [];
+let browser;
+let socket;
+const commands = [];
+const sessions = [];
+try {
+ await symlink(path.join(repo, 'node_modules'), path.join(fixture, 'node_modules'));
+ await writeFile(path.join(fixture, 'index.html'), '');
+ await writeFile(path.join(fixture, 'main.js'), `import { mount } from 'svelte'; import Page from ${JSON.stringify(path.join(repo, 'src/components/workbench/NodeLabPage.svelte'))}; mount(Page, { target: document.getElementById('app') });`);
+ await build({ configFile: false, root: fixture, plugins: [svelte()], build: { outDir: 'dist', emptyOutDir: true } });
+ function rustHost(empty) {
+ const child = spawn(hostBinary, empty ? ['--empty-catalog'] : [], { stdio: ['pipe', 'pipe', 'inherit'] });
+ hosts.push(child);
+ const lines = createInterface({ input: child.stdout });
+ let tail = Promise.resolve();
+ return (command, args) => {
+ const result = tail.then(() => new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => { lines.removeListener('line', received); reject(new Error('Rust host command timeout')); }, 10000);
+ const received = line => { clearTimeout(timeout); try { resolve(JSON.parse(line)); } catch (error) { reject(error); } };
+ lines.once('line', received);
+ child.stdin.write(JSON.stringify({ command, args }) + '\n');
+ }));
+ tail = result.catch(() => {});
+ return result;
+ };
+ }
+ const realHost = rustHost(false);
+ const emptyHost = rustHost(true);
+ async function startServer(mode) {
+ const server = createServer(async (request, response) => {
+ try {
+ const url = new URL(request.url, 'http://127.0.0.1');
+ if (url.pathname.startsWith('/api/workflow/commands/')) {
+ if (request.method !== 'POST') { response.writeHead(405).end(); return; }
+ let body = ''; for await (const chunk of request) { body += chunk; if (body.length > 100000) throw new Error('request too large'); }
+ const command = decodeURIComponent(url.pathname.split('/').at(-1));
+ const args = JSON.parse(body);
+ commands.push(command);
+ const result = mode === 'error' ? { ok: false, error: { code: 'runtime_not_ready', message: 'Qualification host unavailable' } } : await (mode === 'empty' ? emptyHost : realHost)(command, args);
+ if (result.ok && command === 'create_custom_node_draft') sessions.push(result.value.session_id);
+ response.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' }).end(JSON.stringify(result.ok ? result.value : result.error));
+ return;
+ }
+ const relative = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
+ const file = path.resolve(fixture, 'dist', relative);
+ if (!file.startsWith(path.resolve(fixture, 'dist') + path.sep)) { response.writeHead(403).end(); return; }
+ response.setHeader('Content-Type', file.endsWith('.js') ? 'application/javascript' : 'text/html');
+ response.end(await readFile(file));
+ } catch (error) { response.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ code: 'internal_error', message: String(error) })); }
+ });
+ servers.push(server);
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+ return `http://127.0.0.1:${server.address().port}`;
+ }
+ const realOrigin = await startServer('real');
+ const emptyOrigin = await startServer('empty');
+ const errorOrigin = await startServer('error');
+ const profile = path.join(fixture, 'profile');
+ // This disposable container cannot provide the Chromium user-namespace sandbox.
+ // These verification flags do not modify the normal application launcher.
+ browser = spawn(process.env.CHROMIUM_BINARY || 'chromium', ['--headless=new', '--no-sandbox', '--disable-gpu', '--disable-background-networking', '--no-first-run', '--no-default-browser-check', '--user-data-dir=' + profile, '--remote-debugging-port=0', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, XDG_CONFIG_HOME: path.join(fixture, 'config'), XDG_CACHE_HOME: path.join(fixture, 'cache') } });
+ let diagnostics = ''; browser.stderr.on('data', chunk => { diagnostics = (diagnostics + chunk).slice(-16384); });
+ let port;
+ const deadline = Date.now() + 10000;
+ while (!port && Date.now() < deadline) {
+ try { port = (await readFile(path.join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]; }
+ catch { await new Promise(resolve => setTimeout(resolve, 50)); }
+ }
+ assert.ok(port, 'Chromium startup unavailable: ' + diagnostics);
+ const pages = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
+ socket = new WebSocket(pages.find(page => page.type === 'page').webSocketDebuggerUrl);
+ await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }); });
+ let sequence = 0; const pending = new Map();
+ socket.addEventListener('message', event => {
+ const message = JSON.parse(event.data); const item = pending.get(message.id); if (!item) return;
+ pending.delete(message.id); clearTimeout(item.timer);
+ if (message.error) item.reject(new Error(JSON.stringify(message.error))); else item.resolve(message.result);
+ });
+ const call = (method, params = {}) => new Promise((resolve, reject) => {
+ const id = ++sequence;
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error(method + ' timeout')); }, 15000);
+ pending.set(id, { resolve, reject, timer }); socket.send(JSON.stringify({ id, method, params }));
+ });
+ const evaluate = (callback, args) => evaluateBrowserFunction(call, callback, args);
+ const text = () => evaluate(() => document.body.innerText);
+ async function waitText(expected) {
+ const deadline = Date.now() + 10000;
+ while (Date.now() < deadline) { if ((await text()).includes(expected)) return; await new Promise(resolve => setTimeout(resolve, 30)); }
+ assert.fail(`Missing UI text: ${expected}\n${await text()}\nCommands: ${JSON.stringify(commands)}`);
+ }
+ async function navigate(origin) {
+ await call('Page.navigate', { url: `${origin}/?workflowHost=${encodeURIComponent(origin)}` });
+ await waitText('NodeLab');
+ }
+ async function clickButton(label) {
+ const rect = await evaluate(label => {
+ const button = [...document.querySelectorAll('button')].find(item => item.textContent.trim() === label);
+ if (!button || button.disabled) throw new Error('Button unavailable: ' + label);
+ button.scrollIntoView(); const rect = button.getBoundingClientRect(); return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
+ }, [label]);
+ await call('Input.dispatchMouseEvent', { type: 'mousePressed', button: 'left', clickCount: 1, ...rect });
+ await call('Input.dispatchMouseEvent', { type: 'mouseReleased', button: 'left', clickCount: 1, ...rect });
+ }
+ async function enter(selector, value) {
+ await evaluate(selector => { const input = document.querySelector(selector); input.focus(); }, [selector]);
+ await call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2, windowsVirtualKeyCode: 65 });
+ await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', code: 'KeyA', modifiers: 2, windowsVirtualKeyCode: 65 });
+ await call('Input.insertText', { text: value });
+ }
+ await navigate(emptyOrigin); await waitText('This host has no admitted custom nodes');
+ assert.equal(await evaluate(() => [...document.querySelectorAll('button')].some(button => button.textContent.startsWith('Create '))), false);
+ await navigate(errorOrigin); await waitText('Qualification host unavailable');
+ assert.equal(await evaluate(() => document.querySelector('[role="alert"]')?.textContent), 'Qualification host unavailable');
+ await navigate(realOrigin); await waitText('Create Host uppercase draft');
+ // Tab/Enter activation proves keyboard access to creation, followed by real pointer actions.
+ await evaluate(() => document.activeElement?.blur());
+ for (let count = 0; count < 10; count++) {
+ await call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
+ await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
+ if (await evaluate(() => document.activeElement?.textContent === 'Create Host uppercase draft')) break;
+ }
+ assert.equal(await evaluate(() => document.activeElement?.textContent), 'Create Host uppercase draft');
+ assert.equal(await evaluate(() => getComputedStyle(document.activeElement).outlineStyle !== 'none'), true);
+ await call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r', unmodifiedText: '\r' });
+ await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 });
+ await waitText('Graph contract valid');
+ await enter('textarea', 'Saved Mixed case'); await clickButton('Apply input'); await waitText('Input saved in draft');
+ await clickButton('Save workflow'); await waitText('Workflow saved');
+ const savedPath = await evaluate(() => document.querySelector('code')?.textContent);
+ assert.ok(savedPath);
+ const file = (await realHost('load_workflow', { path: savedPath })).value;
+ assert.equal(file.graph.nodes.find(node => node.node_type === 'text-input').data.text, 'Saved Mixed case');
+ const pin = file.graph.nodes.find(node => node.id === 'custom').data.custom_node_identity;
+ await enter('input', savedPath); await clickButton('Reopen saved workflow'); await waitText('Graph contract valid');
+ assert.equal(await evaluate(() => document.querySelector('textarea')?.value), 'Saved Mixed case');
+ const reloaded = (await realHost('load_workflow', { path: savedPath })).value;
+ assert.deepEqual(reloaded.graph.nodes.find(node => node.id === 'custom').data.custom_node_identity, pin);
+ assert.equal((await realHost('get_node_lab_draft', { executionId: sessions[0] })).error.code, 'session_not_found');
+ // Same saved graph opened on a host without its implementation stays visible and blocked.
+ await emptyHost('save_workflow', { name: 'stale', graph: file.graph });
+ await navigate(emptyOrigin); await waitText('This host has no admitted custom nodes');
+ await enter('input', '.pantograph/workflows/stale.json'); await clickButton('Reopen saved workflow'); await waitText('Blocks execution:');
+ assert.ok((await text()).includes('host-uppercase'));
+ assert.ok(!commands.some(command => command.includes('run_execution') || command.includes('register') || command.includes('publish')));
+ console.log(JSON.stringify({ ok: true, claims: ['real empty catalog', 'transport failure distinct from empty', 'keyboard creation and visible focus', 'pointer edit/save/reopen through Rust filesystem', 'exact pin preserved', 'replaced session closed', 'missing implementation visible and blocking'], mode: 'headless Chromium, disposable profile, no sandbox, GPU disabled', nativeTauri: 'not qualified' }, null, 2));
+} finally {
+ socket?.close();
+ if (browser && browser.exitCode === null) { browser.kill('SIGTERM'); await new Promise(resolve => { browser.once('exit', resolve); setTimeout(resolve, 2000).unref(); }); }
+ for (const server of servers) await new Promise(resolve => server.close(resolve));
+ for (const host of hosts) { host.stdin.end(); if (host.exitCode === null) await new Promise(resolve => { host.once('exit', resolve); setTimeout(() => { host.kill('SIGTERM'); resolve(); }, 2000).unref(); }); }
+ await rm(fixture, { recursive: true, force: true });
+}
diff --git a/src-tauri/src/app_setup.rs b/src-tauri/src/app_setup.rs
index 2aa8cee7f..10472587b 100644
--- a/src-tauri/src/app_setup.rs
+++ b/src-tauri/src/app_setup.rs
@@ -461,6 +461,9 @@ pub fn run_app() -> AppStartupResult<()> {
crate::llm::reset_recovery_state,
// Workflow commands
crate::workflow::commands::validate_workflow_connection,
+ crate::workflow::commands::get_custom_node_authoring_catalog,
+ crate::workflow::commands::create_custom_node_draft,
+ crate::workflow::commands::get_node_lab_draft,
crate::workflow::commands::get_node_definitions,
crate::workflow::commands::get_node_definitions_by_category,
crate::workflow::commands::get_node_definition,
diff --git a/src-tauri/src/workflow/commands.rs b/src-tauri/src/workflow/commands.rs
index 6b2b64548..8b5550b93 100644
--- a/src-tauri/src/workflow/commands.rs
+++ b/src-tauri/src/workflow/commands.rs
@@ -49,6 +49,40 @@ pub fn validate_workflow_connection(source_type: PortDataType, target_type: Port
super::workflow_definition_commands::validate_workflow_connection(source_type, target_type)
}
+/// Read-only host admissions; no registration or code upload command.
+#[command]
+pub fn get_custom_node_authoring_catalog(
+ workflow_service: State<'_, SharedWorkflowService>,
+) -> pantograph_workflow_service::custom_nodes::authoring::CustomNodeAuthoringCatalog {
+ workflow_service.custom_node_authoring_catalog()
+}
+
+#[command]
+pub async fn create_custom_node_draft(
+ request: pantograph_workflow_service::custom_nodes::authoring::CustomNodeDraftRequest,
+ workflow_service: State<'_, SharedWorkflowService>,
+) -> Result {
+ workflow_service
+ .create_custom_node_draft(request)
+ .await
+ .map_err(|error| error.to_envelope_json())
+}
+
+#[command]
+pub async fn get_node_lab_draft(
+ execution_id: String,
+ workflow_service: State<'_, SharedWorkflowService>,
+) -> Result {
+ workflow_service
+ .workflow_graph_get_edit_session_graph(
+ pantograph_workflow_service::WorkflowGraphEditSessionGraphRequest {
+ session_id: execution_id,
+ },
+ )
+ .await
+ .map_err(|error| error.to_envelope_json())
+}
+
#[command]
pub fn get_node_definitions() -> Vec {
super::workflow_definition_commands::get_node_definitions()
diff --git a/src/components/workbench/NodeLabPage.svelte b/src/components/workbench/NodeLabPage.svelte
index ffb8f1863..dea945294 100644
--- a/src/components/workbench/NodeLabPage.svelte
+++ b/src/components/workbench/NodeLabPage.svelte
@@ -1,23 +1,90 @@
-
- Node Editor
-
- {nodeLabUnavailableMessage()}
-
-
+
+ NodeLab
+ {nodeLabTrustMessage()}
+
{#each statusRows as row (row.label)}
-
- {row.label}
+ - {row.label}
- {row.value}
{/each}
+ {#if view.error}{view.error}
{/if}
+ {view.busy ? 'Working…' : view.notice}
+ {#if view.pendingCleanup}
+ {view.pendingCleanup} previous NodeLab sessions could not be closed. Close them before creating or reopening a draft.
+
+ {/if}
+ Admitted nodes
+
+ {#if view.catalog?.length === 0}
+ This host has no admitted custom nodes. A host maintainer must register a trusted implementation before creating a new draft.
+ {:else if view.catalog}
+
+ {/if}
+
+ {#if view.savedPath}Saved: {view.savedPath}
{/if}
+ {#if view.draft}
+
+
Workflow draft
+
{view.draft.graph.nodes.length} nodes · {view.draft.graph.edges.length} connections. Saving preserves the selected implementation identity. Run saved workflows from Scheduler.
+ {#if view.draft.graph_diagnostics.length}
+
+ {#each view.draft.graph_diagnostics as diagnostic}
+ - {diagnostic.blocking_submission ? 'Blocks execution: ' : ''}{diagnostic.message}
+ {/each}
+
+ {:else}
Graph contract valid
{/if}
+ {#each view.draft.graph.nodes.filter(node => node.node_type === 'text-input') as node (node.id)}
+
+ {/each}
+
+
+ {/if}
diff --git a/src/components/workbench/nodeLabController.ts b/src/components/workbench/nodeLabController.ts
new file mode 100644
index 000000000..122609ce6
--- /dev/null
+++ b/src/components/workbench/nodeLabController.ts
@@ -0,0 +1,86 @@
+import { NodeLabService, type CustomNodeAuthoringEntry, type NodeLabDraft } from '../../services/workflow/customNodeAuthoring.ts';
+import type { GraphNode } from '../../services/workflow/types.ts';
+
+export interface NodeLabView {
+ catalog: CustomNodeAuthoringEntry[] | null;
+ draft: NodeLabDraft | null;
+ savedPath: string;
+ busy: boolean;
+ error: string | null;
+ notice: string;
+ pendingCleanup: number;
+}
+/** One page owns one separate edit session and observes all in-flight completion. */
+export class NodeLabController {
+ private disposed = false;
+ private operation: Promise | null = null;
+ private readonly ownedSessions = new Set();
+ cleanupError: unknown = null;
+ readonly view: NodeLabView = { catalog: null, draft: null, savedPath: '', busy: false, error: null, notice: '', pendingCleanup: 0 };
+ private readonly service: NodeLabService;
+ private readonly changed: (view: NodeLabView) => void;
+ constructor(service: NodeLabService, changed: (view: NodeLabView) => void) { this.service = service; this.changed = changed; this.view.pendingCleanup = service.pendingCleanupCount; }
+ private publish(): void { if (!this.disposed) this.changed({ ...this.view }); }
+ private perform(action: () => Promise): Promise {
+ if (this.disposed || this.operation) return Promise.resolve();
+ this.view.busy = true; this.view.error = null; this.view.notice = ''; this.publish();
+ const operation = (async () => {
+ try { await action(); }
+ catch (error) { this.view.error = error instanceof Error ? error.message : 'NodeLab operation failed'; }
+ finally { this.view.busy = false; this.operation = null; this.view.pendingCleanup = this.service.pendingCleanupCount; this.publish(); }
+ })();
+ this.operation = operation;
+ return operation;
+ }
+ retryCleanup(): Promise { return this.perform(() => this.service.retryCleanup()); }
+ loadCatalog(): Promise {
+ return this.perform(async () => { this.view.catalog = await this.service.catalog(); });
+ }
+ private async replace(create: () => Promise): Promise {
+ const next = await create();
+ this.ownedSessions.add(next.session_id);
+ if (this.view.draft) {
+ try {
+ await this.service.close(this.view.draft.session_id);
+ this.ownedSessions.delete(this.view.draft.session_id);
+ } catch (error) {
+ this.service.retainActiveSession(this.view.draft.session_id);
+ try { await this.service.close(next.session_id); this.ownedSessions.delete(next.session_id); }
+ catch (cleanupError) { this.cleanupError = cleanupError; throw new AggregateError([error, cleanupError], 'Draft replacement and cleanup failed; previous draft retained'); }
+ throw error;
+ }
+ }
+ this.view.draft = next;
+ this.view.savedPath = '';
+ }
+ create(entry: CustomNodeAuthoringEntry): Promise {
+ return this.perform(() => this.replace(() => this.service.create(entry.identity)));
+ }
+ reopen(path: string): Promise {
+ return this.perform(async () => { await this.replace(() => this.service.reopen(path)); this.view.savedPath = path; });
+ }
+ update(node: GraphNode, text: string): Promise {
+ return this.perform(async () => {
+ if (!this.view.draft) throw new Error('No NodeLab draft');
+ this.view.draft = await this.service.update(this.view.draft.session_id, node, text);
+ this.view.notice = 'Input saved in draft';
+ });
+ }
+ save(name: string): Promise {
+ return this.perform(async () => {
+ if (!this.view.draft) throw new Error('No NodeLab draft');
+ const result = await this.service.save(this.view.draft.session_id, name);
+ this.view.draft = result.draft; this.view.savedPath = result.path; this.view.notice = 'Workflow saved';
+ });
+ }
+ async dispose(): Promise {
+ this.disposed = true;
+ await this.operation;
+ for (const id of this.ownedSessions) {
+ try {
+ await this.service.close(id); this.ownedSessions.delete(id);
+ if (this.view.draft?.session_id === id) this.view.draft = null;
+ } catch (error) { this.cleanupError = error; }
+ }
+ }
+}
diff --git a/src/components/workbench/nodeLabPresenters.test.ts b/src/components/workbench/nodeLabPresenters.test.ts
index a83fd6967..c279c61d0 100644
--- a/src/components/workbench/nodeLabPresenters.test.ts
+++ b/src/components/workbench/nodeLabPresenters.test.ts
@@ -1,16 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
+import { buildNodeLabStatusRows, nodeLabTrustMessage } from './nodeLabPresenters.ts';
-import {
- buildNodeLabStatusRows,
- nodeLabUnavailableMessage,
-} from './nodeLabPresenters.ts';
-
-test('node lab presenters expose a disabled state without authoring affordances', () => {
- assert.equal(nodeLabUnavailableMessage(), 'Node authoring is unavailable in this build');
+test('NodeLab distinguishes admitted graph authoring from unsupported code publishing', () => {
+ assert.match(nodeLabTrustMessage(), /trusted in-process/);
+ assert.match(nodeLabTrustMessage(), /cannot install or publish code/);
assert.deepEqual(buildNodeLabStatusRows(), [
- { label: 'Authoring API', value: 'Unavailable' },
+ { label: 'Graph authoring', value: 'Host-admitted nodes' },
{ label: 'Local Agent', value: 'Unavailable' },
- { label: 'Runtime Publishing', value: 'Unavailable' },
+ { label: 'Runtime Publishing', value: 'Unsupported' },
]);
});
diff --git a/src/components/workbench/nodeLabPresenters.ts b/src/components/workbench/nodeLabPresenters.ts
index 530bb2dd5..abea6cb53 100644
--- a/src/components/workbench/nodeLabPresenters.ts
+++ b/src/components/workbench/nodeLabPresenters.ts
@@ -1,16 +1,11 @@
-export interface NodeLabStatusRow {
- label: string;
- value: string;
-}
-
+export interface NodeLabStatusRow { label: string; value: string }
export function buildNodeLabStatusRows(): NodeLabStatusRow[] {
return [
- { label: 'Authoring API', value: 'Unavailable' },
+ { label: 'Graph authoring', value: 'Host-admitted nodes' },
{ label: 'Local Agent', value: 'Unavailable' },
- { label: 'Runtime Publishing', value: 'Unavailable' },
+ { label: 'Runtime Publishing', value: 'Unsupported' },
];
}
-
-export function nodeLabUnavailableMessage(): string {
- return 'Node authoring is unavailable in this build';
+export function nodeLabTrustMessage(): string {
+ return 'These nodes are supplied by this host and run as trusted in-process code. NodeLab creates workflows; it cannot install or publish code.';
}
diff --git a/src/services/workflow/customNodeAuthoring.test.ts b/src/services/workflow/customNodeAuthoring.test.ts
new file mode 100644
index 000000000..88d976738
--- /dev/null
+++ b/src/services/workflow/customNodeAuthoring.test.ts
@@ -0,0 +1,143 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { NodeLabService, parseCustomNodeCatalog, parseNodeLabDraft } from './customNodeAuthoring.ts';
+import { NodeLabController } from '../../components/workbench/nodeLabController.ts';
+
+const identity = { node_type: 'custom-uppercase', contract_version: '1.0.0', contract_digest: `contract-blake3:${'c'.repeat(64)}`, implementation_digest: `blake3:${'a'.repeat(64)}`, environment_digest: `blake3:${'b'.repeat(64)}`, adapter_version: 1 };
+const entry = { identity, label: 'Uppercase', description: 'Host transform', trust: 'trusted_in_process', inputs: [{ id: 'input', label: 'Input' }], outputs: [{ id: 'output', label: 'Output' }] };
+const catalog = () => ({ contract_version: 1, nodes: [structuredClone(entry)] });
+const snapshot = (id = 'edit-1') => ({ session_id: id, graph_revision: 'revision-1', graph: { nodes: [{ id: 'source', node_type: 'text-input', position: { x: 0, y: 0 }, data: { text: 'draft' } }], edges: [] }, graph_diagnostics: [] });
+
+test('catalog decoder requires complete v1 identity, trust, ports and extra-field policy', () => {
+ assert.equal(parseCustomNodeCatalog(catalog())[0].identity.environment_digest, identity.environment_digest);
+ assert.deepEqual(parseCustomNodeCatalog({ contract_version: 1, nodes: [] }), []);
+ const invalid: unknown[] = [null, [], { ...catalog(), contract_version: 2 }, { ...catalog(), extra: true }, { ...catalog(), nodes: {} }];
+ for (const patch of [
+ { trust: 'sandbox' }, { inputs: [] }, { outputs: [{ id: 'output', label: 1 }] },
+ { identity: { ...identity, adapter_version: 2 } }, { identity: { ...identity, implementation_digest: 'unverified' } },
+ { identity: { ...identity, extra: true } }, { identity: { ...identity, contract_digest: 'bad' } }, { identity: { ...identity, contract_version: 'not-semver' } }, { identity: { ...identity, node_type: 'bad type' } }, { inputs: [{ id: 'x', label: 'X' }, { id: 'x', label: 'Again' }] },
+ ]) invalid.push({ contract_version: 1, nodes: [{ ...entry, ...patch }] });
+ for (const value of invalid) assert.throws(() => parseCustomNodeCatalog(value));
+ assert.throws(() => parseCustomNodeCatalog({ contract_version: 1, nodes: [entry, entry] }));
+});
+
+test('draft decoder refuses malformed graph and diagnostics before exposing state', () => {
+ assert.equal(parseNodeLabDraft(snapshot()).graph.nodes[0].data.text, 'draft');
+ for (const value of [{ ...snapshot(), graph: { nodes: [{}], edges: [] } }, { ...snapshot(), graph_diagnostics: [{ message: 'invalid' }] }, { ...snapshot(), session_id: '' }, { ...snapshot(), graph_diagnostics: null }]) assert.throws(() => parseNodeLabDraft(value));
+});
+
+test('service forwards exact selection and refuses changed-session response', async () => {
+ const calls: [string, Record | undefined][] = [];
+ const service = new NodeLabService(async (command, args) => { calls.push([command, args]); return snapshot(); });
+ await service.create(identity);
+ assert.deepEqual(calls[0], ['create_custom_node_draft', { request: { identity } }]);
+ await assert.rejects(service.snapshot('other-session'), /correlation mismatch/);
+});
+
+test('invalid newly created response closes its session instead of leaking it', async () => {
+ const closed: string[] = [];
+ const service = new NodeLabService(async (command, args) => {
+ if (command === 'remove_execution') { closed.push(String(args?.executionId)); return null; }
+ return { session_id: 'created', graph: 'broken' };
+ });
+ await assert.rejects(service.create(identity), /Invalid NodeLab text/);
+ assert.deepEqual(closed, ['created']);
+});
+
+test('controller distinguishes empty catalog from failure and closes superseded drafts', async () => {
+ let fail = false;
+ let counter = 0;
+ const closed: string[] = [];
+ const service = new NodeLabService(async (command, args) => {
+ if (command === 'get_custom_node_authoring_catalog') {
+ if (fail) throw new Error('host unavailable');
+ return { contract_version: 1, nodes: [] };
+ }
+ if (command === 'remove_execution') { closed.push(String(args?.executionId)); return null; }
+ return snapshot(`edit-${++counter}`);
+ });
+ const controller = new NodeLabController(service, () => {});
+ await controller.loadCatalog(); assert.deepEqual(controller.view.catalog, []); assert.equal(controller.view.error, null);
+ fail = true; await controller.loadCatalog(); assert.equal(controller.view.error, 'host unavailable');
+ const selected = parseCustomNodeCatalog(catalog())[0];
+ await controller.create(selected); await controller.create(selected);
+ assert.deepEqual(closed, ['edit-1']);
+ await controller.dispose(); assert.deepEqual(closed, ['edit-1', 'edit-2']);
+});
+
+test('dispose observes in-flight creation, prevents obsolete publication and releases the late session', async () => {
+ let complete!: (value: unknown) => void;
+ const pending = new Promise(resolve => { complete = resolve; });
+ const closed: string[] = [];
+ let published = 0;
+ const service = new NodeLabService(async (command, args) => {
+ if (command === 'remove_execution') { closed.push(String(args?.executionId)); return null; }
+ return pending;
+ });
+ const controller = new NodeLabController(service, () => { published++; });
+ const create = controller.create(parseCustomNodeCatalog(catalog())[0]);
+ const before = published;
+ const disposed = controller.dispose();
+ complete(snapshot('late'));
+ await Promise.all([create, disposed]);
+ assert.equal(published, before); assert.deepEqual(closed, ['late']); assert.equal(controller.cleanupError, null);
+});
+
+test('close failure compensates the new session and retains the old draft', async () => {
+ let created = 0;
+ const closed: unknown[] = [];
+ const service = new NodeLabService(async (command, args) => {
+ if (command === 'remove_execution') {
+ closed.push(args?.executionId);
+ if (args?.executionId === 'edit-1') throw new Error('close refused');
+ return null;
+ }
+ return snapshot(`edit-${++created}`);
+ });
+ const controller = new NodeLabController(service, () => {});
+ const selected = parseCustomNodeCatalog(catalog())[0];
+ await controller.create(selected); await controller.create(selected);
+ assert.equal(created, 2); assert.equal(controller.view.error, 'close refused');
+ assert.equal(controller.view.draft?.session_id, 'edit-1');
+ assert.deepEqual(closed, ['edit-1', 'edit-2']);
+ await controller.dispose(); assert.match(String(controller.cleanupError), /close refused/);
+});
+
+test('failed stale selection or reopen retains current edited draft and save path', async () => {
+ let refuse = false;
+ const closed: unknown[] = [];
+ const service = new NodeLabService(async (command, args) => {
+ if (command === 'remove_execution') { closed.push(args?.executionId); return null; }
+ if (refuse) throw new Error('selection/path unavailable');
+ return snapshot('current');
+ });
+ const controller = new NodeLabController(service, () => {});
+ const selected = parseCustomNodeCatalog(catalog())[0];
+ await controller.create(selected);
+ controller.view.savedPath = 'existing.json';
+ const before = structuredClone(controller.view.draft);
+ refuse = true;
+ await controller.create(selected);
+ assert.deepEqual(controller.view.draft, before); assert.equal(controller.view.savedPath, 'existing.json');
+ await controller.reopen('missing.json');
+ assert.deepEqual(controller.view.draft, before); assert.equal(controller.view.savedPath, 'existing.json');
+ assert.deepEqual(closed, []);
+ await controller.dispose(); assert.deepEqual(closed, ['current']);
+});
+
+test('app service retains failed malformed-response cleanup across page owners until explicit recovery', async () => {
+ let closeFails = true;
+ const service = new NodeLabService(async command => {
+ if (command === 'remove_execution') { if (closeFails) throw new Error('offline'); return null; }
+ return { session_id: 'known-created', graph_revision: 'revision', graph: null };
+ });
+ await assert.rejects(service.create(identity), /offline/);
+ assert.equal(service.pendingCleanupCount, 1);
+ const nextPage = new NodeLabController(service, () => {});
+ assert.equal(nextPage.view.pendingCleanup, 1);
+ await assert.rejects(service.create(identity), /Close previous/);
+ closeFails = false;
+ await nextPage.retryCleanup();
+ assert.equal(nextPage.view.pendingCleanup, 0); assert.equal(service.pendingCleanupCount, 0);
+ await nextPage.dispose();
+});
diff --git a/src/services/workflow/customNodeAuthoring.ts b/src/services/workflow/customNodeAuthoring.ts
new file mode 100644
index 000000000..995ea61e0
--- /dev/null
+++ b/src/services/workflow/customNodeAuthoring.ts
@@ -0,0 +1,184 @@
+// Wire authority: custom_nodes/authoring.rs (catalog v1) and graph/session_contract.rs.
+import type { GraphNode, WorkflowGraph } from './types.ts';
+import { invokeWorkflowCommand } from './workflowServiceErrors.ts';
+
+export interface CustomNodeIdentity {
+ node_type: string;
+ contract_version: string;
+ contract_digest: string;
+ implementation_digest: string;
+ environment_digest: string;
+ adapter_version: number;
+}
+export interface CustomNodeAuthoringEntry {
+ identity: CustomNodeIdentity;
+ label: string;
+ description: string;
+ trust: 'trusted_in_process';
+ inputs: { id: string; label: string }[];
+ outputs: { id: string; label: string }[];
+}
+export interface NodeLabDraft {
+ session_id: string;
+ graph_revision: string;
+ graph: WorkflowGraph;
+ graph_diagnostics: { message: string; blocking_submission: boolean }[];
+}
+function record(value: unknown): Record {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid NodeLab object');
+ return value as Record;
+}
+function exact(value: unknown, keys: string[]): Record {
+ const item = record(value);
+ if (Object.keys(item).length !== keys.length || keys.some(key => !(key in item))) throw new Error('Invalid NodeLab fields');
+ return item;
+}
+function text(value: unknown): string {
+ if (typeof value !== 'string') throw new Error('Invalid NodeLab text');
+ return value;
+}
+function identifier(value: unknown): string {
+ const id = text(value);
+ if (!id.trim()) throw new Error('Invalid NodeLab identifier');
+ return id;
+}
+function contractId(value: unknown): string {
+ const id = identifier(value);
+ // NodeTypeId/PortId grammar in pantograph-node-contracts; do not normalize pins.
+ if (id.length > 128 || !/^[A-Za-z0-9_.:-]+$/.test(id)) throw new Error('Invalid custom-node contract identifier');
+ return id;
+}
+export function parseCustomNodeIdentity(value: unknown): CustomNodeIdentity {
+ const item = exact(value, ['node_type', 'contract_version', 'contract_digest', 'implementation_digest', 'environment_digest', 'adapter_version']);
+ if (item.adapter_version !== 1) throw new Error('Unsupported custom-node adapter version');
+ for (const key of ['implementation_digest', 'environment_digest']) {
+ if (!/^blake3:[0-9a-f]{64}$/.test(text(item[key]))) throw new Error('Invalid custom-node attestation');
+ }
+ if (!/^[0-9]+\.[0-9]+\.[0-9]+$/.test(text(item.contract_version)) || !/^contract-blake3:[0-9a-f]{64}$/.test(text(item.contract_digest))) throw new Error('Invalid custom-node contract identity');
+ return {
+ node_type: contractId(item.node_type), contract_version: text(item.contract_version),
+ contract_digest: identifier(item.contract_digest), implementation_digest: text(item.implementation_digest),
+ environment_digest: text(item.environment_digest), adapter_version: 1,
+ };
+}
+export function parseCustomNodeCatalog(value: unknown): CustomNodeAuthoringEntry[] {
+ const catalog = exact(value, ['contract_version', 'nodes']);
+ if (catalog.contract_version !== 1) throw new Error('Unsupported NodeLab catalog version');
+ if (!Array.isArray(catalog.nodes)) throw new Error('Invalid NodeLab catalog');
+ const seen = new Set();
+ return catalog.nodes.map(value => {
+ const item = exact(value, ['identity', 'label', 'description', 'trust', 'inputs', 'outputs']);
+ const identity = parseCustomNodeIdentity(item.identity);
+ if (seen.has(identity.node_type)) throw new Error('Duplicate NodeLab type');
+ seen.add(identity.node_type);
+ if (item.trust !== 'trusted_in_process') throw new Error('Unsupported custom-node trust profile');
+ const ports = (value: unknown) => {
+ if (!Array.isArray(value) || value.length < 1 || value.length > 8) throw new Error('Invalid NodeLab ports');
+ const seen = new Set();
+ return value.map(value => {
+ const port = exact(value, ['id', 'label']);
+ const id = contractId(port.id);
+ if (id.startsWith('_')) throw new Error('Invalid reserved custom-node port');
+ if (seen.has(id)) throw new Error('Duplicate NodeLab port');
+ seen.add(id);
+ return { id, label: text(port.label) };
+ });
+ };
+ return { identity, label: text(item.label), description: text(item.description), trust: 'trusted_in_process', inputs: ports(item.inputs), outputs: ports(item.outputs) };
+ });
+}
+function parseGraph(value: unknown): WorkflowGraph {
+ const graph = record(value);
+ if (!Array.isArray(graph.nodes) || !Array.isArray(graph.edges)) throw new Error('Invalid NodeLab graph');
+ const nodes = graph.nodes.map(value => {
+ const node = record(value);
+ const position = record(node.position);
+ if (typeof position.x !== 'number' || !Number.isFinite(position.x) || typeof position.y !== 'number' || !Number.isFinite(position.y)) throw new Error('Invalid NodeLab position');
+ return { id: identifier(node.id), node_type: identifier(node.node_type), position: { x: position.x, y: position.y }, data: record(node.data) };
+ });
+ const edges = graph.edges.map(value => {
+ const edge = record(value);
+ return { id: identifier(edge.id), source: identifier(edge.source), source_handle: identifier(edge.source_handle), target: identifier(edge.target), target_handle: identifier(edge.target_handle) };
+ });
+ return { nodes, edges };
+}
+export function parseNodeLabDraft(value: unknown): NodeLabDraft {
+ const draft = record(value);
+ const diagnostics = draft.graph_diagnostics === undefined ? [] : draft.graph_diagnostics;
+ if (!Array.isArray(diagnostics)) throw new Error('Invalid NodeLab diagnostics');
+ return {
+ session_id: identifier(draft.session_id), graph_revision: identifier(draft.graph_revision), graph: parseGraph(draft.graph),
+ graph_diagnostics: diagnostics.map(value => {
+ const item = record(value);
+ if (typeof item.blocking_submission !== 'boolean') throw new Error('Invalid NodeLab diagnostic');
+ return { message: text(item.message), blocking_submission: item.blocking_submission };
+ }),
+ };
+}
+
+export class NodeLabService {
+ // App-scoped recovery ownership for transport/close failures, never an execution registry.
+ private readonly pendingCloses = new Set();
+ get pendingCleanupCount(): number { return this.pendingCloses.size; }
+ // A failed replacement keeps the old draft under its still-active controller.
+ retainActiveSession(id: string): void { this.pendingCloses.delete(id); }
+ async retryCleanup(): Promise {
+ const errors: unknown[] = [];
+ for (const id of [...this.pendingCloses]) { try { await this.close(id); } catch (error) { errors.push(error); } }
+ if (errors.length) throw new AggregateError(errors, 'Some NodeLab sessions could not be closed');
+ }
+ private requireCleanupResolved(): void {
+ if (this.pendingCloses.size) throw new Error('Close previous NodeLab sessions before creating or reopening a draft');
+ }
+ private readonly invoke: (command: string, args?: Record) => Promise;
+ constructor(invoke: (command: string, args?: Record) => Promise = invokeWorkflowCommand) { this.invoke = invoke; }
+ async catalog(): Promise {
+ return parseCustomNodeCatalog(await this.invoke('get_custom_node_authoring_catalog'));
+ }
+ private async ownedDraft(response: unknown): Promise {
+ const id = identifier(record(response).session_id);
+ try { return parseNodeLabDraft(response); }
+ catch (error) { await this.close(id); throw error; }
+ }
+ async create(identity: CustomNodeIdentity): Promise {
+ this.requireCleanupResolved();
+ return this.ownedDraft(await this.invoke('create_custom_node_draft', { request: { identity } }));
+ }
+ async reopen(path: string): Promise {
+ this.requireCleanupResolved();
+ const saved = record(await this.invoke('load_workflow', { path }));
+ const graph = parseGraph(saved.graph);
+ const session = record(await this.invoke('create_workflow_execution_session', { graph, workflowId: path }));
+ const id = identifier(session.session_id);
+ try { return await this.snapshot(id); }
+ catch (error) { await this.close(id); throw error; }
+ }
+ async snapshot(id: string): Promise {
+ const draft = parseNodeLabDraft(await this.invoke('get_node_lab_draft', { executionId: id }));
+ if (draft.session_id !== id) throw new Error('NodeLab session correlation mismatch');
+ return draft;
+ }
+ async update(id: string, node: GraphNode, value: string): Promise {
+ // Source values are authored text; custom identities/definitions never use this path.
+ if (node.node_type !== 'text-input') throw new Error('NodeLab only edits text sources');
+ const draft = parseNodeLabDraft(await this.invoke('update_node_data', { executionId: id, nodeId: node.id, data: { text: value } }));
+ if (draft.session_id !== id) throw new Error('NodeLab session correlation mismatch');
+ return draft;
+ }
+ async save(id: string, name: string): Promise<{ draft: NodeLabDraft; path: string }> {
+ const draft = await this.snapshot(id);
+ const path = identifier(await this.invoke('save_workflow', { name, graph: draft.graph }));
+ return { draft, path };
+ }
+ async close(id: string): Promise {
+ try { await this.invoke('remove_execution', { executionId: id }); this.pendingCloses.delete(id); }
+ catch (error) {
+ // Already absent is a terminal close outcome; other failures retain exact ownership.
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'session_not_found') { this.pendingCloses.delete(id); return; }
+ this.pendingCloses.add(id); throw error;
+ }
+ }
+}
+
+// Workbench lifetime owner: failed closes survive NodeLab page navigation.
+export const nodeLabService = new NodeLabService();
From d503ed305448e9e36b8a54f2bc393d263803fe58 Mon Sep 17 00:00:00 2001
From: Jeremy
Date: Thu, 22 Oct 2026 02:35:34 +0000
Subject: [PATCH 2/5] fix(nodelab): key projected graph diagnostics
---
.../evidence/nodelab-authoring/browser.log.gz | Bin 441 -> 441 bytes
.../nodelab-authoring/qualification.json | 4 ++--
src/components/workbench/NodeLabPage.svelte | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz
index ec6e6c01c5b6d92e7d5f711e3f8f19252feaee63..685a06aa6d63b9419d3576a7161c5a2a3d99454b 100644
GIT binary patch
delta 318
zcmV-E0m1&c1Gxi`8GqCi)FUAERv{<{gg7|%B$=-N@Yt)QhzqX(N1m=v!Z@oGslNI9
znfYeyi3-AWt*+Dn+e*$-*uySnNIBYS3dNw5>Ppsu1@=O>`_tdwFbr4VMdXm-nMbR-
zjKN}Vh0LLbk(fdMwJ)%6SOdZjjJYfImTEkFU*YV(G>A00p+$Nok}skmr2
z<1~2|l1$fsc|qx(q#SKEg=jbtUV-0(+s`{qf%)7={ga5jkXd;?b%u
zW3ZT8A#iUIsy;4_aYfImTEkFU*YV(@ApB+FS$Nok}skmr2
z<1~2|{view.draft.graph.nodes.length} nodes · {view.draft.graph.edges.length} connections. Saving preserves the selected implementation identity. Run saved workflows from Scheduler.
{#if view.draft.graph_diagnostics.length}
- {#each view.draft.graph_diagnostics as diagnostic}
+ {#each view.draft.graph_diagnostics as diagnostic, index (index)}
- {diagnostic.blocking_submission ? 'Blocks execution: ' : ''}{diagnostic.message}
{/each}
From 802544a0ed5f523d2ce7a54f7859b8ed0187e63f Mon Sep 17 00:00:00 2001
From: Jeremy
Date: Thu, 22 Oct 2026 02:40:26 +0000
Subject: [PATCH 3/5] test(nodelab): qualify browser with application styles
---
.../evidence/nodelab-authoring/browser.log.gz | Bin 441 -> 584 bytes
.../nodelab-authoring/qualification.json | 7 ++++---
.../execution-ledger.md | 2 ++
scripts/check-nodelab-browser.mjs | 8 ++++----
4 files changed, 10 insertions(+), 7 deletions(-)
diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser.log.gz
index 685a06aa6d63b9419d3576a7161c5a2a3d99454b..07f9f0f9027ca4ed6844bfd1846afd03d511f9e3 100644
GIT binary patch
literal 584
zcmV-O0=NAiiwFP!0000218q~wZWBQeyzf`E%mpNSY-8+zPbdL#LW&R(LWslcPRHBv
zyqKPG>>w`u0v!3ed6E-ABr_#0#Js8t73JsyCw8FM_!J{E6ROoRTtd&
zzSj6o-?R@BYU8lE{qYT^)4A9(#UjJ6{?MogkjJb5AGdFVMZdP**~Q`H5Og{^UNMsQI*VYwvZ{8OXyz&1c+vmKhJida#s16dcx9
zXEiw%>Y7|7r(po@72oov~L)nIst>}MB4%5bU4mV{8k1!JV<4X~0UF9<)*Bor3=uu#K83RC;Z0#FL9)LcXLLs%Zncz-
z(p*d^C$K?t3?+=Ry0zpCgz<4dC^;dN
zF%5dTEV06HCiWZ^H>`7rfpz7|?^K-*!3SUwYQuI2ug))OPE9y?7etgDr7clE{zhP`rbe
zXQhS}jBnxT$xb<_rx2%~s8fh35*d-rs+E__8*4>VW|^uhsPn>w2X&XKPE}hAlt=
z)@t*HyB{4uAIJViMya@HH{U7UXN!qVyTH1N5n!!04h~-1m!_IZvNrF0j+&YDQ?`
zC>Q~9^t0(A9`Uq|I$JPOuE>^cc#V@RF`2
z-MVN-(e&c@c3zZx0+coN2h>)gFuap^o~mE8J*3FGdF4a3qzecUSPE_9aRIjt@9dEeVj
diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json
index 75b65155d..1d75ffd22 100644
--- a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json
+++ b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/qualification.json
@@ -5,7 +5,8 @@
"rust": "1.92.0",
"node": "24.12.0",
"chromium": "Chromium 151.0.7922.173 built on Debian GNU/Linux 13 (trixie)",
- "mode": "headless; disposable profile/store; GPU disabled; container verification no-sandbox only"
+ "mode": "headless; disposable profile/store; GPU disabled; container verification no-sandbox only",
+ "application_styles": true
},
"claims": {
"browser_user_workflow": "passed: actual NodeLab -> loopback -> Rust service -> filesystem create/edit/save/reopen; keyboard/pointer and focus; missing implementation blocking; exact pin retained",
@@ -37,7 +38,7 @@
"crates/pantograph-workflow-service/src/workflow/service_config.rs": "257edc58a50b37e8dee265b971e91084b0f77b0ce4031f8d105b604f33cea513",
"crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs": "aa080d798774771aa419db01d83cd26401684ab06333770a74e209c601e5952f",
"scripts/README.md": "03bed3d3d157a4d7e4a9f2a291f4a2ab37efa2e81f87c83e3d5710a38220119a",
- "scripts/check-nodelab-browser.mjs": "30a619d382ddb242c19b50c51ec97fda8a6c20973fe51f94c7c0fcf5658d24d4",
+ "scripts/check-nodelab-browser.mjs": "91505dbfa49defa836a246fcff7317bc6dae795fd257da1ee8747146cebe3357",
"src-tauri/src/app_setup.rs": "b14c996956638c5a708156aaf8f110ce6f1fa3a175f6b03bb9ff0284bd392a50",
"src-tauri/src/workflow/commands.rs": "24834569833ec6509300b9c8c795b9eb8be233f0c6d9c37c5d67f82b63d24b6c",
"src/components/workbench/NodeLabPage.svelte": "ab23063b86e78e3dc919602df48d027f0e736bee678b8e5609eedc41d0da25d2",
@@ -48,7 +49,7 @@
"src/services/workflow/customNodeAuthoring.ts": "f4a11c2fa37816819db79176b2af584199c946a5e3c533482cc595362092dc08"
},
"evidence_sha256": {
- "browser.log.gz": "6a1c002a0a7a2aa962249b01f31b98f5f33e5643f43472cb07b390d8d75c0f74",
+ "browser.log.gz": "6c2a15df9371220e78251fd39f27631010da2e63d41f45843e9c9dba0c3bc97e",
"native-unavailable.log.gz": "f72bc764c185d785964c3ac95e9a0d6f24a1d999aefbd349dc143eed28d261b3"
}
}
diff --git a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md
index e6a6f09de..1f3defa2e 100644
--- a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md
+++ b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md
@@ -836,6 +836,8 @@ an unexported provider and a nonexistent response field; corrected against the
actual owning APIs, preserving the assertions. The first browser Enter simulation
omitted character text and did not activate the button; proper CDP key events
then passed the same workflow assertions, with no pointer fallback for that gate.
+Full lint required keys on stateless diagnostic rows; repaired. Final browser
+evidence loads the actual application stylesheet and checks nonzero focus outline.
Native `cargo check --locked -p pantograph --bin pantograph --no-default-features`
is unavailable at glib-sys because this environment lacks `glib-2.0.pc`; GTK and
diff --git a/scripts/check-nodelab-browser.mjs b/scripts/check-nodelab-browser.mjs
index 76cd26290..76e5496a5 100644
--- a/scripts/check-nodelab-browser.mjs
+++ b/scripts/check-nodelab-browser.mjs
@@ -26,8 +26,8 @@ const sessions = [];
try {
await symlink(path.join(repo, 'node_modules'), path.join(fixture, 'node_modules'));
await writeFile(path.join(fixture, 'index.html'), '');
- await writeFile(path.join(fixture, 'main.js'), `import { mount } from 'svelte'; import Page from ${JSON.stringify(path.join(repo, 'src/components/workbench/NodeLabPage.svelte'))}; mount(Page, { target: document.getElementById('app') });`);
- await build({ configFile: false, root: fixture, plugins: [svelte()], build: { outDir: 'dist', emptyOutDir: true } });
+ await writeFile(path.join(fixture, 'main.js'), `import ${JSON.stringify(path.join(repo, 'src/styles.css'))}; import { mount } from 'svelte'; import Page from ${JSON.stringify(path.join(repo, 'src/components/workbench/NodeLabPage.svelte'))}; mount(Page, { target: document.getElementById('app') });`);
+ await build({ configFile: false, root: fixture, css: { postcss: repo }, plugins: [svelte()], build: { outDir: 'dist', emptyOutDir: true } });
function rustHost(empty) {
const child = spawn(hostBinary, empty ? ['--empty-catalog'] : [], { stdio: ['pipe', 'pipe', 'inherit'] });
hosts.push(child);
@@ -64,7 +64,7 @@ try {
const relative = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
const file = path.resolve(fixture, 'dist', relative);
if (!file.startsWith(path.resolve(fixture, 'dist') + path.sep)) { response.writeHead(403).end(); return; }
- response.setHeader('Content-Type', file.endsWith('.js') ? 'application/javascript' : 'text/html');
+ response.setHeader('Content-Type', file.endsWith('.js') ? 'application/javascript' : file.endsWith('.css') ? 'text/css' : 'text/html');
response.end(await readFile(file));
} catch (error) { response.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ code: 'internal_error', message: String(error) })); }
});
@@ -140,7 +140,7 @@ try {
if (await evaluate(() => document.activeElement?.textContent === 'Create Host uppercase draft')) break;
}
assert.equal(await evaluate(() => document.activeElement?.textContent), 'Create Host uppercase draft');
- assert.equal(await evaluate(() => getComputedStyle(document.activeElement).outlineStyle !== 'none'), true);
+ assert.equal(await evaluate(() => getComputedStyle(document.activeElement).outlineStyle !== 'none' && parseFloat(getComputedStyle(document.activeElement).outlineWidth) > 0), true);
await call('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r', unmodifiedText: '\r' });
await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 });
await waitText('Graph contract valid');
From ff4e4de05d884f05338e70745db2400df0134926 Mon Sep 17 00:00:00 2001
From: Jeremy
Date: Thu, 22 Oct 2026 04:10:18 +0000
Subject: [PATCH 4/5] fix(nodelab): hide stale catalog choices after refresh
failure
---
.../nodelab-authoring/browser-refresh.log.gz | Bin 0 -> 617 bytes
.../catalog-refresh-red.log.gz | Bin 0 -> 1146 bytes
.../nodelab-authoring/qualification.json | 15 ++++++----
.../execution-ledger.md | 18 ++++++++++++
scripts/check-nodelab-browser.mjs | 27 ++++++++++++++++--
src/components/workbench/nodeLabController.ts | 6 +++-
.../workflow/customNodeAuthoring.test.ts | 17 +++++++----
7 files changed, 68 insertions(+), 15 deletions(-)
create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-refresh.log.gz
create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/catalog-refresh-red.log.gz
diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-refresh.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-refresh.log.gz
new file mode 100644
index 0000000000000000000000000000000000000000..391ed64af588415c6df5b84da9b76f650b9e8068
GIT binary patch
literal 617
zcmV-v0+#(BiwFP!0000219ekPZxb;Pz3;CWg$qcDleA3=IiXgF3x^7U5JCuf*OTlt
z{=#FY35W}S0gn8-{u0JX0|mt1{5Wr{_HU;fajCZRW&Y7kvt{FRPBUJ6H3wKe;CpH9M)?>b*X72J$dh^9eVfCF4O@4;B(c!C^gh
zR+D2v-Zj<7K!US?F6JF{^2#=>2E)V6KD!uFhNUK35<;Dhd?!E(?m#*guW{D~oeXp$
zShowbH?Se5)}kz4SNK>O1Nn>z&`=(;-rzW4i0C2rDU?+YFAGZxlK0zjMt9`lR!i$B
z=3+uwfeo7DRl;a>hXxp#YxB+q{(bF8PRl%D*<`ku
zZQZ-h7x>_o75>CSQ8_r+GF2G66`a`I)J(*y+>IB0S5%36d%
zf=l0|B|36!hp?F6P;U%LE=EUiR+g=D8k~c}R+od5Y73^Tl2~qybAVif`IAN=s6u0?
zAX4NG<+c#{PS}0OQJPLhi&{{>{6WYDvV9rXu96`+qxhp_fK1~uFraZ<=1k{81wQpH
z_bAJ<{pAfehb6CUtl4twXqu+UWOOl4XPv0EfERQaeP^9c_$G$tu=(&1y&FCm`&cOR
zG|bW3X8Qq6dR6(2v9L^nMFQK|MK=B)wqyt=!)zWwTGBW&ZG^Z;=5pnV%36al#Z4+AxuDjh5Gr5DH?=&K~92WHy+%_PomF4
zxs_#r$f)}rW)fx}(=JG(+i$NmlQBCnC6HmI7j_9sE&`1EC#Nxmpt9t0jr0P(*%_=9
zdlb_wf8*1A(Ot`?J5EN)=bTcy@C1+^!#v
z7R~J+TRO)xQ~7_zqQ3VxG;Zp{*LcL&Ij+ZV!Ix4mOKZ?Bt}(669`HFJT`5&}Occ4N
zkEq?ZkQe?0BwsUa^nPn9_YL(5G-);*#@XUZmWyG@v^ViZKL}+3&*6WcLW@N<9=$vI
zXKt_a$Re>7-b(>Fli*`DQ^R1j#6IS9Txa9)c$!_jY<~#*2cbDU-c_r5UcUx;k)}Y5
z loopback -> Rust service -> filesystem create/edit/save/reopen; keyboard/pointer and focus; missing implementation blocking; exact pin retained",
+ "browser_user_workflow": "passed: actual NodeLab -> loopback -> Rust service -> filesystem create/edit/save/reopen; keyboard/pointer and focus; missing implementation blocking; exact pin retained; latest repaired-source run in browser-refresh.log.gz; original browser.log.gz retained as baseline evidence",
"service_tests": "1068 library +65 integration passed, zero failures/ignored",
"frontend_tests": "740 passed, zero failures/ignored; 10 focused decoding/controller/presenter tests included",
"native_tauri": "unavailable: cargo check --locked -p pantograph --bin pantograph --no-default-features fails at glib-sys: missing glib-2.0.pc; pkg-config also lacks GTK/WebKit; no native startup claim",
@@ -20,7 +20,8 @@
"GPU",
"inference-backed/effectful tool loops",
"release qualification"
- ]
+ ],
+ "catalog_refresh": "failed refresh after successful load removes stale choices while preserving draft and saved path; focused red on baseline 802544a0, green after repair; same mounted real-browser flow passed"
},
"source_sha256": {
"crates/pantograph-workflow-service/examples/nodelab_host.rs": "1c9a33092e0bac9560247b8c1e158bc0405527cad7b0a280d79dc61334eed8c8",
@@ -38,18 +39,20 @@
"crates/pantograph-workflow-service/src/workflow/service_config.rs": "257edc58a50b37e8dee265b971e91084b0f77b0ce4031f8d105b604f33cea513",
"crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs": "aa080d798774771aa419db01d83cd26401684ab06333770a74e209c601e5952f",
"scripts/README.md": "03bed3d3d157a4d7e4a9f2a291f4a2ab37efa2e81f87c83e3d5710a38220119a",
- "scripts/check-nodelab-browser.mjs": "91505dbfa49defa836a246fcff7317bc6dae795fd257da1ee8747146cebe3357",
+ "scripts/check-nodelab-browser.mjs": "82f884c2fb04394168c06ba09b0432345aad059e67bc54efdc281a759b68c88f",
"src-tauri/src/app_setup.rs": "b14c996956638c5a708156aaf8f110ce6f1fa3a175f6b03bb9ff0284bd392a50",
"src-tauri/src/workflow/commands.rs": "24834569833ec6509300b9c8c795b9eb8be233f0c6d9c37c5d67f82b63d24b6c",
"src/components/workbench/NodeLabPage.svelte": "ab23063b86e78e3dc919602df48d027f0e736bee678b8e5609eedc41d0da25d2",
- "src/components/workbench/nodeLabController.ts": "3b3806dcf0aedf191fd7ad91ce3ac746a08c6962b1c0b292f54d9ba6e9145716",
+ "src/components/workbench/nodeLabController.ts": "0389ac2397e253926162195a1b41bddf1e07568f65afd568e1673b4fe44c0d1f",
"src/components/workbench/nodeLabPresenters.test.ts": "5debffcdba6ddc86d4b4093dceb08dbf691b7659b73a483abd8d991616faa02e",
"src/components/workbench/nodeLabPresenters.ts": "4792bedc18bf6b55173a892e0f15bc7900b4c36fd84bfc508da3dac9c0bbb075",
- "src/services/workflow/customNodeAuthoring.test.ts": "73d776ffb40ddcafcfb0e69f7434f98aa19bd037484a36ccdef7d9eff290d250",
+ "src/services/workflow/customNodeAuthoring.test.ts": "97aa21f8487984832d03efac4f81d694b214ed065087099aca4a9b87433083dd",
"src/services/workflow/customNodeAuthoring.ts": "f4a11c2fa37816819db79176b2af584199c946a5e3c533482cc595362092dc08"
},
"evidence_sha256": {
"browser.log.gz": "6c2a15df9371220e78251fd39f27631010da2e63d41f45843e9c9dba0c3bc97e",
- "native-unavailable.log.gz": "f72bc764c185d785964c3ac95e9a0d6f24a1d999aefbd349dc143eed28d261b3"
+ "native-unavailable.log.gz": "f72bc764c185d785964c3ac95e9a0d6f24a1d999aefbd349dc143eed28d261b3",
+ "catalog-refresh-red.log.gz": "c849e1ead56e2b00b0f7fc3c7e04a4cf867a99fe176476aa9b0d9658613bf720",
+ "browser-refresh.log.gz": "2ddc4d6339e12a6c43cff584156c871b94352780d060b6ee4aa49495975884e2"
}
}
diff --git a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md
index 1f3defa2e..9cae9a29c 100644
--- a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md
+++ b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md
@@ -848,3 +848,21 @@ first by parent instruction; CN-02 must then reconcile current main, preserve it
registry/loop validations, rerun combined affected tests and pass exact-head CI
before merge. The local/remote feature branch and worktree are retained pending
that disposition, with source and evidence preserved.
+
+While dependency integration was pending, a reachable catalog-refresh failure
+left earlier Create choices visible. The focused regression failed on `802544a0`
+and passes after clearing/publishing the catalog before transport; authored draft
+and saved path remain unchanged. The same mounted real-browser flow passes
+failed refresh and recovery. Its first probe inspected old-session closure before
+Reopen completed; bounded observation now waits for replacement completion and
+retains the original closure assertion. Original browser/native receipts remain;
+the qualification manifest binds the repaired source and additional receipts.
+No backend, scheduler or audit behavior changed. This repair remains local until
+the required PR94 integration/current-main qualification.
+
+Hosted PR95 source/build/native-IPC checks passed on `802544a0`; the required
+audit failed with an actual advisory-bulk HTTP 503. PR94 likewise remains blocked
+on audit service failures. No audit retries or gate changes were requested here.
+Local native provisioning inspection stopped when `apt-cache policy` was denied
+reading `/etc/apt/apt.conf.d/80-applied-apt-retries`; no package installation or
+privileged retry was attempted. This does not qualify native startup/interaction.
diff --git a/scripts/check-nodelab-browser.mjs b/scripts/check-nodelab-browser.mjs
index 76e5496a5..55ae40d26 100644
--- a/scripts/check-nodelab-browser.mjs
+++ b/scripts/check-nodelab-browser.mjs
@@ -23,6 +23,7 @@ let browser;
let socket;
const commands = [];
const sessions = [];
+let failRealCatalog = false;
try {
await symlink(path.join(repo, 'node_modules'), path.join(fixture, 'node_modules'));
await writeFile(path.join(fixture, 'index.html'), '');
@@ -56,7 +57,7 @@ try {
const command = decodeURIComponent(url.pathname.split('/').at(-1));
const args = JSON.parse(body);
commands.push(command);
- const result = mode === 'error' ? { ok: false, error: { code: 'runtime_not_ready', message: 'Qualification host unavailable' } } : await (mode === 'empty' ? emptyHost : realHost)(command, args);
+ const result = (mode === 'error' || (mode === 'real' && failRealCatalog && command === 'get_custom_node_authoring_catalog')) ? { ok: false, error: { code: 'runtime_not_ready', message: 'Qualification host unavailable' } } : await (mode === 'empty' ? emptyHost : realHost)(command, args);
if (result.ok && command === 'create_custom_node_draft') sessions.push(result.value.session_id);
response.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' }).end(JSON.stringify(result.ok ? result.value : result.error));
return;
@@ -151,18 +152,38 @@ try {
const file = (await realHost('load_workflow', { path: savedPath })).value;
assert.equal(file.graph.nodes.find(node => node.node_type === 'text-input').data.text, 'Saved Mixed case');
const pin = file.graph.nodes.find(node => node.id === 'custom').data.custom_node_identity;
- await enter('input', savedPath); await clickButton('Reopen saved workflow'); await waitText('Graph contract valid');
+ await enter('input', savedPath); await clickButton('Reopen saved workflow');
+ const reopenDeadline = Date.now() + 10000;
+ while ((await realHost('get_node_lab_draft', { executionId: sessions[0] })).ok && Date.now() < reopenDeadline) {
+ await new Promise(resolve => setTimeout(resolve, 30));
+ }
+ while (await evaluate(() => document.querySelector('[aria-label="NodeLab authoring"]')?.getAttribute('aria-busy') === 'true')) {
+ assert.ok(Date.now() < reopenDeadline, 'Reopen must finish before inspecting the replacement');
+ await new Promise(resolve => setTimeout(resolve, 30));
+ }
+ await waitText('Graph contract valid');
assert.equal(await evaluate(() => document.querySelector('textarea')?.value), 'Saved Mixed case');
const reloaded = (await realHost('load_workflow', { path: savedPath })).value;
assert.deepEqual(reloaded.graph.nodes.find(node => node.id === 'custom').data.custom_node_identity, pin);
assert.equal((await realHost('get_node_lab_draft', { executionId: sessions[0] })).error.code, 'session_not_found');
+ // A failed refresh cannot advertise stale implementations or discard the authored draft.
+ failRealCatalog = true;
+ await clickButton('Refresh catalog'); await waitText('Qualification host unavailable');
+ assert.equal(await evaluate(() => [...document.querySelectorAll('button')].some(button => button.textContent.startsWith('Create '))), false);
+ assert.equal(await evaluate(() => document.querySelector('textarea')?.value), 'Saved Mixed case');
+ assert.equal(await evaluate(() => document.querySelector('code')?.textContent), savedPath);
+ assert.equal((await text()).includes('This host has no admitted custom nodes'), false);
+ failRealCatalog = false;
+ await clickButton('Refresh catalog'); await waitText('Create Host uppercase draft');
+ assert.equal(await evaluate(() => document.querySelector('textarea')?.value), 'Saved Mixed case');
+ assert.equal(await evaluate(() => document.querySelector('code')?.textContent), savedPath);
// Same saved graph opened on a host without its implementation stays visible and blocked.
await emptyHost('save_workflow', { name: 'stale', graph: file.graph });
await navigate(emptyOrigin); await waitText('This host has no admitted custom nodes');
await enter('input', '.pantograph/workflows/stale.json'); await clickButton('Reopen saved workflow'); await waitText('Blocks execution:');
assert.ok((await text()).includes('host-uppercase'));
assert.ok(!commands.some(command => command.includes('run_execution') || command.includes('register') || command.includes('publish')));
- console.log(JSON.stringify({ ok: true, claims: ['real empty catalog', 'transport failure distinct from empty', 'keyboard creation and visible focus', 'pointer edit/save/reopen through Rust filesystem', 'exact pin preserved', 'replaced session closed', 'missing implementation visible and blocking'], mode: 'headless Chromium, disposable profile, no sandbox, GPU disabled', nativeTauri: 'not qualified' }, null, 2));
+ console.log(JSON.stringify({ ok: true, claims: ['real empty catalog', 'transport failure distinct from empty', 'keyboard creation and visible focus', 'pointer edit/save/reopen through Rust filesystem', 'exact pin preserved', 'replaced session closed', 'missing implementation visible and blocking', 'failed catalog refresh hides stale choices and preserves draft'], mode: 'headless Chromium, disposable profile, no sandbox, GPU disabled', nativeTauri: 'not qualified' }, null, 2));
} finally {
socket?.close();
if (browser && browser.exitCode === null) { browser.kill('SIGTERM'); await new Promise(resolve => { browser.once('exit', resolve); setTimeout(resolve, 2000).unref(); }); }
diff --git a/src/components/workbench/nodeLabController.ts b/src/components/workbench/nodeLabController.ts
index 122609ce6..575872ffb 100644
--- a/src/components/workbench/nodeLabController.ts
+++ b/src/components/workbench/nodeLabController.ts
@@ -34,7 +34,11 @@ export class NodeLabController {
}
retryCleanup(): Promise { return this.perform(() => this.service.retryCleanup()); }
loadCatalog(): Promise {
- return this.perform(async () => { this.view.catalog = await this.service.catalog(); });
+ return this.perform(async () => {
+ this.view.catalog = null;
+ this.publish();
+ this.view.catalog = await this.service.catalog();
+ });
}
private async replace(create: () => Promise): Promise {
const next = await create();
diff --git a/src/services/workflow/customNodeAuthoring.test.ts b/src/services/workflow/customNodeAuthoring.test.ts
index 88d976738..30a1f1ace 100644
--- a/src/services/workflow/customNodeAuthoring.test.ts
+++ b/src/services/workflow/customNodeAuthoring.test.ts
@@ -45,22 +45,29 @@ test('invalid newly created response closes its session instead of leaking it',
});
test('controller distinguishes empty catalog from failure and closes superseded drafts', async () => {
- let fail = false;
+ let mode: 'empty' | 'full' | 'failed' = 'empty';
let counter = 0;
const closed: string[] = [];
const service = new NodeLabService(async (command, args) => {
if (command === 'get_custom_node_authoring_catalog') {
- if (fail) throw new Error('host unavailable');
- return { contract_version: 1, nodes: [] };
+ if (mode === 'failed') throw new Error('host unavailable');
+ return mode === 'empty' ? { contract_version: 1, nodes: [] } : catalog();
}
if (command === 'remove_execution') { closed.push(String(args?.executionId)); return null; }
return snapshot(`edit-${++counter}`);
});
const controller = new NodeLabController(service, () => {});
await controller.loadCatalog(); assert.deepEqual(controller.view.catalog, []); assert.equal(controller.view.error, null);
- fail = true; await controller.loadCatalog(); assert.equal(controller.view.error, 'host unavailable');
+ mode = 'full'; await controller.loadCatalog(); assert.equal(controller.view.catalog?.length, 1);
const selected = parseCustomNodeCatalog(catalog())[0];
- await controller.create(selected); await controller.create(selected);
+ await controller.create(selected);
+ controller.view.savedPath = 'saved-current.json';
+ const before = structuredClone(controller.view.draft);
+ mode = 'failed'; await controller.loadCatalog(); assert.equal(controller.view.error, 'host unavailable');
+ assert.equal(controller.view.catalog, null, 'failed refresh must not advertise stale creation choices');
+ assert.deepEqual(controller.view.draft, before, 'catalog failure must preserve the current draft');
+ assert.equal(controller.view.savedPath, 'saved-current.json');
+ await controller.create(selected);
assert.deepEqual(closed, ['edit-1']);
await controller.dispose(); assert.deepEqual(closed, ['edit-1', 'edit-2']);
});
From b4854d8d62bb58ef2917694ffcd8355733c252d7 Mon Sep 17 00:00:00 2001
From: Jeremy
Date: Thu, 22 Oct 2026 04:28:19 +0000
Subject: [PATCH 5/5] test(nodelab): qualify edit sessions with integrated loop
admission
---
.github/workflows/quality-gates.yml | 6 ++
.../src/workflow/tests/custom_nodes.rs | 90 ++++++++++++++++++
docs/custom-nodes.md | 8 ++
.../nodelab-authoring/browser-combined.log.gz | Bin 0 -> 618 bytes
.../nodelab-authoring/ci-discovery.log.gz | Bin 0 -> 1531 bytes
.../nodelab-authoring/qualification.json | 38 ++++++--
.../nodelab-authoring/service-combined.log.gz | Bin 0 -> 20631 bytes
.../execution-ledger.md | 41 +++++++-
.../plan.md | 14 +++
9 files changed, 186 insertions(+), 11 deletions(-)
create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-combined.log.gz
create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/ci-discovery.log.gz
create mode 100644 docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/service-combined.log.gz
diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml
index 4b002a144..815184f09 100644
--- a/.github/workflows/quality-gates.yml
+++ b/.github/workflows/quality-gates.yml
@@ -434,6 +434,12 @@ jobs:
python3 -c 'import pathlib, sys; names = [line for line in pathlib.Path(sys.argv[1]).read_text().splitlines() if "tool_loop" in line and line.endswith(": test")]; print("Tool-loop tests discovered:", len(names)); sys.exit(0 if len(names) == 15 else 1)' "$RUNNER_TEMP/tool-loop-tests.list"
cargo test --locked -p pantograph-workflow-service --lib tool_loop
+ - name: Run exact-head custom-node authoring and admission regression suite
+ run: |
+ cargo test --locked -p pantograph-workflow-service --lib workflow::tests::custom_nodes:: -- --list > "$RUNNER_TEMP/custom-node-tests.list"
+ python3 -c 'import pathlib, sys; names = [line for line in pathlib.Path(sys.argv[1]).read_text().splitlines() if line.startswith("workflow::tests::custom_nodes::") and line.endswith(": test")]; print("Custom-node tests discovered:", len(names)); sys.exit(0 if len(names) == 10 else 1)' "$RUNNER_TEMP/custom-node-tests.list"
+ cargo test --locked -p pantograph-workflow-service --lib workflow::tests::custom_nodes::
+
- name: Run complete session execution regression suite
run: |
cargo test -p pantograph-workflow-service --lib workflow::tests::session_execution:: -- --list > "$RUNNER_TEMP/session-execution-tests.list"
diff --git a/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs b/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs
index 9226d62a0..76584515d 100644
--- a/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs
+++ b/crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs
@@ -631,3 +631,93 @@ async fn custom_node_authoring_saved_reopen_preserves_pin_and_scheduler_executio
.unwrap();
assert_eq!(result.outputs[0].value, "MIXED CASE");
}
+
+#[tokio::test]
+async fn custom_node_edit_sessions_preserve_integrated_loop_admission() {
+ use crate::tool_loops::tests::{catalog as loop_catalog, Executor as LoopExecutor, Mode};
+ use crate::tool_loops::TOOL_LOOP_IDENTITY_FIELD;
+
+ let controller = LoopExecutor::new(Mode::RoundTrip);
+ let tool = LoopExecutor::new(Mode::Uppercase);
+ let catalog = loop_catalog(controller.clone(), tool.clone(), 3);
+ let mut graph = custom_graph(catalog.get("loop-controller").unwrap().identity());
+ let node = graph.find_node_mut("custom").unwrap();
+ node.node_type = "tool-loop".into();
+ node.data = serde_json::json!({TOOL_LOOP_IDENTITY_FIELD: catalog.tool_loop_identity("pure-loop").unwrap()});
+ graph.edges[0].target_handle = "prompt".into();
+ graph.edges[1].source_handle = "response".into();
+ let root = tempfile::tempdir().unwrap();
+ let store = FileSystemWorkflowGraphStore::new(root.path());
+ let service = WorkflowService::with_trusted_custom_nodes(catalog.clone());
+ let saved = service
+ .workflow_graph_save(
+ &store,
+ crate::WorkflowGraphSaveRequest {
+ name: "integrated-loop".into(),
+ graph,
+ },
+ )
+ .unwrap();
+ let graph = service
+ .workflow_graph_load(
+ &store,
+ crate::WorkflowGraphLoadRequest {
+ path: saved.path.clone(),
+ },
+ )
+ .unwrap()
+ .graph;
+ let pin = graph.find_node("custom").unwrap().data[TOOL_LOOP_IDENTITY_FIELD].clone();
+ let changed = loop_catalog(controller.clone(), tool.clone(), 4);
+ for (service, blocking) in [
+ (service, false),
+ (WorkflowService::with_trusted_custom_nodes(changed), true),
+ (WorkflowService::new(), true),
+ ] {
+ let edit = service
+ .workflow_graph_create_edit_session(crate::WorkflowGraphEditSessionCreateRequest {
+ graph: graph.clone(),
+ workflow_id: Some(saved.path.clone()),
+ })
+ .await
+ .unwrap();
+ let request = crate::WorkflowGraphEditSessionGraphRequest {
+ session_id: edit.session_id.clone(),
+ };
+ let snapshot = service
+ .workflow_graph_get_edit_session_graph(request.clone())
+ .await
+ .unwrap();
+ assert_eq!(
+ snapshot.graph.find_node("custom").unwrap().data[TOOL_LOOP_IDENTITY_FIELD],
+ pin
+ );
+ assert_eq!(
+ snapshot
+ .graph_diagnostics
+ .iter()
+ .any(|d| d.blocking_submission),
+ blocking,
+ "{:?}",
+ snapshot.graph_diagnostics
+ );
+ let mut invalid = snapshot.graph.find_node("custom").unwrap().clone();
+ invalid.id = "invalid-new-loop".into();
+ invalid.data[TOOL_LOOP_IDENTITY_FIELD]["max_turns"] = serde_json::json!(5);
+ assert!(service
+ .workflow_graph_add_node(crate::WorkflowGraphAddNodeRequest {
+ session_id: edit.session_id,
+ node: invalid,
+ })
+ .await
+ .is_err());
+ let after = service
+ .workflow_graph_get_edit_session_graph(request)
+ .await
+ .unwrap();
+ assert_eq!(after.graph_revision, snapshot.graph_revision);
+ assert_eq!(after.graph, snapshot.graph);
+ }
+ assert!(controller.calls.lock().is_empty());
+ assert!(tool.calls.lock().is_empty());
+}
diff --git a/docs/custom-nodes.md b/docs/custom-nodes.md
index 6eebe7fe5..20ce7362f 100644
--- a/docs/custom-nodes.md
+++ b/docs/custom-nodes.md
@@ -132,5 +132,13 @@ retired. Inference-backed controllers, side-effectful tools, external workers,
Wasm, Python, package discovery, automatic updates, streaming and inference ports
require follow-on admission; none are inferred from a palette entry.
+NodeLab's creation catalog projects individual CN-01 registrations, not loop
+templates or tool grants. Existing host-pinned loop graphs can be reopened using
+the same registry; their pins remain unchanged and unavailable/stale grants are
+blocking. A future loop-authoring surface must select a complete host-admitted
+`ToolLoopIdentity` and produce exactly one prompt binding under the
+[CN-03 contract](tool-loops.md); optional authoring ports and new grants remain
+unsupported. No scheduler or loop authority is owned by NodeLab.
+
See [ADR-018](adr/ADR-018-trusted-custom-node-adapter.md) and the
[admitted implementation slice](plans/domain-architecture-and-multimodal/plan.md#admitted-custom-node-adapter-slice-2026-10-21).
diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-combined.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/browser-combined.log.gz
new file mode 100644
index 0000000000000000000000000000000000000000..a137fef0cf1f89dac4cd954458a0a0685ec949cc
GIT binary patch
literal 618
zcmV-w0+szAiwFP!0000219el&ZWBQeyzf`E%mpNS94B!eKA}X23x^1S5JCve?zFuf
z&x`39#}3McUw|WDmoK5mjv;{9+thSbch&T+C?l*+)UldCTgV#n3z&xlF$Gh)Ou;Lq
znw+#}ft_5}^F<0bjEOCAoKj@qGNkVNLHupci!DKR(u}rbZ@XH?>)g<%=Qw^f)i(hgzERwP0iY;y_0@E#4
z*i?0r^0n%-v+&P%peCoVyny@fU$@NJ7CIJa$JNZ3c_%Tte1A=&n-@o_V+NQWsgt9>
zxaaEZKio)k^XikkR@Xv>>8Uz7{*!xnpr(hFTfNt(&Ojc{)af}lUnJu}SPo_qM8RP_
zbykyOK|VCq$3TL!fG*|@bn?nJEC$2f%{IFjQii!ETM|N@j(jIT3T{W*7cX(s2AvFa
zB3QQzv^TIKrq-e?URQWu8Uy)^3D8g;vtHpiVTkA<_bHS`53dVL3zGNSa7MT1;aW@U
zDCS~9S%DRr{Z+zfb%zERnQQaT2L65RNKkoVa?zqgpLY}Pr>m2;Hgrp=*;A@z(R+wQ
zc5*H~d+5o?RE`Gl3yk1c^-(!$8FrQh$vwXdf87g#BeDl9ThjMX~w|1
zP9Q&J^*aO~fJLtj>mj_ndRuF1!OqFR%iJq`qf4Ty=|hIkMO&JaR5W+ZPnj3L%Jl;P
E0G*>FyZ`_I
literal 0
HcmV?d00001
diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/ci-discovery.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/ci-discovery.log.gz
new file mode 100644
index 0000000000000000000000000000000000000000..f81bcbc8b299786b4c6a46ee848ef39efcc048ea
GIT binary patch
literal 1531
zcmVTU%F3A=ljOhHlcizR
zYCbn&iGSvLK6hFdG>oEg)c%81tk_0sLnRl=Nq0?+a7-#o-Ad4mDXnD23d)7ejI10+
za=8S`oCK`IW-GycAq+s7T~lrF0IUon@#rQU05efgmOEi6Hc%D9Nv&vQ#0LB}JS(N6
zOmQrv5X=fonRBA7TE;Dyy#=4~Qd%1jjrbELv(&4#(wB-_#|lBYuL|tPiOAqps=eZZ
zQ|8@D8*IN5Nj>;)%rjv=b+Am>f)!?i^;Sk=4{}qJRLo#!6l)O1u4L7PfeXe9_?AWu
zzyd`-m-N8F4>hVeThkWiS=5#V!=f6FCUx@7RhUl)-V|43t)7>
zh5@$z1%gyGFXVFN+J}&IrLBmT;57HxP^UGwo@7~&iVdD@{3otf43QJueJSA+@9+rP
zqjB=3HtRn4B{nK)rOlpAgZOr$>VoE2S%t8rL9<0z={B-p2c;bhv(am8;K*}f!Fi!C
zk5Lchmql%DBd@V9xPl>Sam3M4eQjV_sn-2?U`~LygQ=6H2o`SKcvI$0wuy`A$79(B
z=88#c=Zj!)wQQm`*Jc@lG={A8Rq)X*LEDSZ2we%aBlaP
zAOr|ZT1v%g=WtDfAdqnp+aXani8nBE<+mZ1pnyc8r({Y}p|VxU%-Y^k29Aoz@lxv{
zG)z7G*KQpqhnl$L5N^HmCL!BZSiQJ{_~^E<++mO|EJox$h^Th3MXHa{U1yFqU@GQ9
zHQ5Gt>D|5h>PB2Io?34pI`~6w(V$QyM1DGAwKW;}-Mw
z;AsWXumw{eMok~CHrJrF$yQ7)#bGjQ({}A5v7fF6klmVm8tBk-p`lo=_WG_}f7PGs
zICUTUD?^W^ZnjtnTF!1cC#JpF7+*4@V8uDfr6{;1iwYEMDHc!3!ZGA;hhJbM3obS?
z6AJ>55{2qyh(WuP5HcFQCRvcIok*3Vh(cr&YqCk59%4Njr?NA~Hh%zxCMWOjCpTU)P&edkMNe@30iffaW1A-=p1
zG!&2{%-RPv=G)fRzr=@c`Mqe-)cE>|ASi&a`0itH{Yhw9Q7pJP{j?w_p_UPyZ{K=_
zY|ruPlxRiLYb+pV=(m!cZWddgV}2piQ?f*e#0*Aeq!|{N$s4Icih-1ZNEZYNJeP$a
zAHC8Qv>pohr^ak2_B8lzHl97RaWZ-DRX`%>dR-Jb2@(S#1zSkJFyrX_#d&&`J4AHjyO4L?{8VSD(oZ#ZlJ!`~pThaLf2(wenSP;)#OhYC_Li<0^Z9a=!If61lN
zrv6zXyZHEfHobU>Hl}d#HWART@UN+$D2>zc>?K;eyUvr}n!9h!-B;e+O~)56zoO=D
zy5HPA@)gYeY3b1~VjfQ4|Au5B=YwxChVHfR{mlR2(w loopback -> Rust service -> filesystem create/edit/save/reopen; keyboard/pointer and focus; missing implementation blocking; exact pin retained; latest repaired-source run in browser-refresh.log.gz; original browser.log.gz retained as baseline evidence",
- "service_tests": "1068 library +65 integration passed, zero failures/ignored",
+ "browser_user_workflow": "passed on combined source: actual styled NodeLab -> loopback -> rebuilt Rust service -> filesystem create/edit/save/reopen; keyboard/pointer and focus; exact pin retained; missing implementation blocking; failed catalog refresh/recovery preserves draft/path. Latest receipt browser-combined.log.gz",
+ "service_tests": "1084 library +65 integration passed, zero failures/ignored; both custom-node and pure-loop suites included",
"frontend_tests": "740 passed, zero failures/ignored; 10 focused decoding/controller/presenter tests included",
"native_tauri": "unavailable: cargo check --locked -p pantograph --bin pantograph --no-default-features fails at glib-sys: missing glib-2.0.pc; pkg-config also lacks GTK/WebKit; no native startup claim",
"not_claimed": [
@@ -21,11 +21,12 @@
"inference-backed/effectful tool loops",
"release qualification"
],
- "catalog_refresh": "failed refresh after successful load removes stale choices while preserving draft and saved path; focused red on baseline 802544a0, green after repair; same mounted real-browser flow passed"
+ "catalog_refresh": "focused red on 802544a0, green after repair; same mounted combined-source browser flow removes stale choices and preserves draft and saved path",
+ "hosted_test_discovery": "local exact CI commands discovered and passed 15 tool_loop tests and 10 workflow::tests::custom_nodes:: tests; original loop gate unchanged"
},
"source_sha256": {
"crates/pantograph-workflow-service/examples/nodelab_host.rs": "1c9a33092e0bac9560247b8c1e158bc0405527cad7b0a280d79dc61334eed8c8",
- "crates/pantograph-workflow-service/src/custom_nodes.rs": "1d413febbad16689c864e7945191c7bb83079530ece13fda73541178bd86f774",
+ "crates/pantograph-workflow-service/src/custom_nodes.rs": "93b016626ce454091fe6092807442184556f90b6f4ae65125af9c12a1e9c52aa",
"crates/pantograph-workflow-service/src/custom_nodes/authoring.rs": "ab2e37ca4a68b846e8f23897891eea064730a1376c8597eb5ce00e1bf5f909ed",
"crates/pantograph-workflow-service/src/graph/connection_insert.rs": "881afe99675e681a2bd1208f69e55f82afa881adec9ead9a6863089dd3a6f2af",
"crates/pantograph-workflow-service/src/graph/connection_intent.rs": "d5832d05d88dd0d2dc10cb367038a0a6d5973e1275fe19e888fb411c13057199",
@@ -37,8 +38,8 @@
"crates/pantograph-workflow-service/src/workflow.rs": "47e42061802abc91ebe6b3babf8c578116d2e0fecf51d86fa53148cbed613886",
"crates/pantograph-workflow-service/src/workflow/custom_node_authoring_api.rs": "5bf54f01b2db4ec2ffc21b6af41291c695ff91a3b274deb80838c32020b9941d",
"crates/pantograph-workflow-service/src/workflow/service_config.rs": "257edc58a50b37e8dee265b971e91084b0f77b0ce4031f8d105b604f33cea513",
- "crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs": "aa080d798774771aa419db01d83cd26401684ab06333770a74e209c601e5952f",
- "scripts/README.md": "03bed3d3d157a4d7e4a9f2a291f4a2ab37efa2e81f87c83e3d5710a38220119a",
+ "crates/pantograph-workflow-service/src/workflow/tests/custom_nodes.rs": "39ea2a0a726a4a93e6199f6cf72d22914db7d43e4edc8d750178acc5084c55ec",
+ "scripts/README.md": "a1b99bbc7b161a5842b49b4a97e08c8042121a2b034848c35c53e22e3dbd881c",
"scripts/check-nodelab-browser.mjs": "82f884c2fb04394168c06ba09b0432345aad059e67bc54efdc281a759b68c88f",
"src-tauri/src/app_setup.rs": "b14c996956638c5a708156aaf8f110ce6f1fa3a175f6b03bb9ff0284bd392a50",
"src-tauri/src/workflow/commands.rs": "24834569833ec6509300b9c8c795b9eb8be233f0c6d9c37c5d67f82b63d24b6c",
@@ -47,12 +48,31 @@
"src/components/workbench/nodeLabPresenters.test.ts": "5debffcdba6ddc86d4b4093dceb08dbf691b7659b73a483abd8d991616faa02e",
"src/components/workbench/nodeLabPresenters.ts": "4792bedc18bf6b55173a892e0f15bc7900b4c36fd84bfc508da3dac9c0bbb075",
"src/services/workflow/customNodeAuthoring.test.ts": "97aa21f8487984832d03efac4f81d694b214ed065087099aca4a9b87433083dd",
- "src/services/workflow/customNodeAuthoring.ts": "f4a11c2fa37816819db79176b2af584199c946a5e3c533482cc595362092dc08"
+ "src/services/workflow/customNodeAuthoring.ts": "f4a11c2fa37816819db79176b2af584199c946a5e3c533482cc595362092dc08",
+ ".github/workflows/quality-gates.yml": "030e50d32426e0abaa17990e683e67a29b9cdb704a2a146908c78817e5369d2f",
+ "crates/pantograph-workflow-service/src/graph/registry.rs": "ad7cfd660e85e264a0c8f9e2ddd78fa3d4fd3074ee2275a13ee33c3f998434c7",
+ "crates/pantograph-workflow-service/src/tool_loops.rs": "3dee015aefb679de08564a02ed4175d902c9836c520c1b0de072cc5a036aaa09",
+ "crates/pantograph-workflow-service/src/scheduler/tool_loop.rs": "3af80db2ce8f4a621e2e1778e1c4dede603068fc12457c8a550d752d9a0bf362"
},
"evidence_sha256": {
"browser.log.gz": "6c2a15df9371220e78251fd39f27631010da2e63d41f45843e9c9dba0c3bc97e",
"native-unavailable.log.gz": "f72bc764c185d785964c3ac95e9a0d6f24a1d999aefbd349dc143eed28d261b3",
"catalog-refresh-red.log.gz": "c849e1ead56e2b00b0f7fc3c7e04a4cf867a99fe176476aa9b0d9658613bf720",
- "browser-refresh.log.gz": "2ddc4d6339e12a6c43cff584156c871b94352780d060b6ee4aa49495975884e2"
+ "browser-refresh.log.gz": "2ddc4d6339e12a6c43cff584156c871b94352780d060b6ee4aa49495975884e2",
+ "browser-combined.log.gz": "a8ef7d653ecfa27ad28d01cf055ee112c4c83b03ee1d3b251e25671b37e7d598",
+ "service-combined.log.gz": "6cd931f20b55b2fc5bfd846b74e7ac0dfbcd4d61e4d175da0228192ec7374029",
+ "ci-discovery.log.gz": "272c0cad07fac982061e125acd110df89918e2147a478a2f30b9051fed19223a"
+ },
+ "integrated_dependency": {
+ "pull_request": 94,
+ "head": "fcb2d7f618f32000ed1992569d7eabf3c77cf873",
+ "merge": "46aad5841ca8f2185e639da44f73474af045bc13",
+ "production_loop_core": "unchanged from merged main; NodeLab consumes the same immutable registry without authoring loop grants"
+ },
+ "historical_evidence": {
+ "browser.log.gz": "802544a0ed5f523d2ce7a54f7859b8ed0187e63f; initial styled flow",
+ "browser-refresh.log.gz": "ff4e4de05d884f05338e70745db2400df0134926; refresh repair",
+ "catalog-refresh-red.log.gz": "regression failed on 802544a0 before repair",
+ "native-unavailable.log.gz": "original missing-GLib failure; retained unchanged"
}
}
diff --git a/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/service-combined.log.gz b/docs/plans/domain-architecture-and-multimodal/evidence/nodelab-authoring/service-combined.log.gz
new file mode 100644
index 0000000000000000000000000000000000000000..ca5b09ba36c795fed8ef60905417d7326d6131d4
GIT binary patch
literal 20631
zcmV(sK<&RDiwFP!000021I>L|k1NS?-uwKD1`Jqt0L^md%r0Ai0YQKP8xUl@2{Nb1
zN|JTESVh&+efnBI-Wh8~t*n(-7tm;iO=jd085!#r?@^H@+2!|_bK=steEH)ZonE7h
z`{Qq4{-~nj;wt}dQ5L`a5xckKzkfLuQRT|-lI-jX
zmz~^qWpVnhbg4U4ELowxU+b(cUF`q#^-G?;96$U7&QYCKUw!p1eDjB%x&M8d
z9KU|KxXi(Hc-7{Kdup#@qI%
z@@qJ|Pv3t3xx^p8{BxbbNH2AkRPcL$rk=$-LC;PvfBNn{FJ4Q3lK
zPSNS=zM~Ppi`}ul_Q}F-ockJWfvbUtjvzyB*`QG8xeY|}8?YA%a>nbXGEIRPbRZ$#O(cwL*u6gZ$
zTxWjUo&V~+uBOwx!QD92rN0{G0X}*y51Ie0Ja{>ADDu>mTPz^+Uksnawv>lFJJi{0
zmcM62aSJ+ke>V>-1eBV;QgA|U3j&3!@DH-x*CeYpnYkVC@D`=@x()Gz-lEjs!ty{{
z?EfPw*Ny4L2b@~^VfeAZc891uT%#hE-@;W`K+u2SveG{;o(W@5_x0_7m;Z3i3oqWX
zj20Z;La+kKGxIHM>^u?
zX(xKf3GT~1O-@P0E1Wy_&E0apA^ty-n}1+DjX(RjmxsT&BIm(M&%g5rN=lV0?^g%H
z1qXr&CuOyKVqs2)DzBpSkbAueo}2kY;{Jv4N}A~1AK|1ML&pr?_ty}PY)w
zy>v?A3FF7Nq=ZYRJ6LCBeTO^iKuyVzx9FisG91*iPx*M@Q{*L2nns|19^xXu;{%x{
zmuq!6*Hv9Odc2rw>Cei4;uVeCy?Q*la4;PbOcNe-81R@^*TXe0D;&%Z3lo?--=p&~
z2IS>iexgrM|Mv1HO-t6R^J2=NX$q40;5onxEtsFzgp!lL`kp^O?jQvGXL0U-R~_P{
zJf(T*4*D}t<0ouJm?nsMkRi0;yw4(7{Qr6RKWQ)gY2+6@oVW%d0!)ZITqkLj_$`TU
zsP&xEItHe@`xEqAj*m}+2I_sd!?|BOJa{-+yY=!17t}-27ULWg6TdmvkGPhf
zS9+@;x&4IsGcOpDya2+wViFa)V3I`iUUXj6Vd^QUDrNPN!q$0Nf|tE)e9k7u!Th?c
z3s1>`%mUGTs=WmB--nId>}XMv7H>vh3uj8&Ej{!g7~&-Iucw#Hr*hJLKslw?h#6ll
zagmo^urn7N^E!+Dqd##wWnK9Vq_Fvd+!cQxJlFVLIHq*P>0wtFuP8RMSCH_%K
z(;K`1F1q1e@Gl@a0%-kfR~Vg6?(SD8%;2~DR3vvV2ub_2=QoE#A8siM@e{6&%yCd*
z&G&%VTw3}UzppXheb0tw3-09>dVJ4^x&?1lkCBy0FxYsitV
z9P*bbjr_ZC{!kUqS_}Re3dTvAytzY~=dbmBTgqY$&=JymfJ+{{a06bQvOdCwDcPgQo|i
z;6b&Pre4(5)R~SupFu*tIlB|0E$iP4-YXHg_<0vglr3^J;ra_C_AiZ@t<6nJ;wR<>rs*$&eM2CRT1o`d;L;)~NQc
zS<8wPqJz&jnf;^$=J(ti$REZB*ZCVbVM=nNz~ypZz)pr>L{fUS>_dYC+bs_@yTF=s
zD^w>d^7|d%C(@=jTW0(H!&x2OHG1;~t=G?rvg3f6ObO+nAJi4zJoazR$&09qc;cFb
z_)l9hiwJ+&P-EV(FHZeqePl2GCAlANb=4A=UQkp_j=n-h+$m2}&w|!LY+^j_y+}X{
zWpg!gb4b=1{efwc$p{{FyoQx4GB2P5F)@jU#dO#S{5h$;AbW#f@qSM~&?W8vJ2;XY
zg@s|i{bju8Wt470K+FN)z|>r3V*+it4)fH=GWwnBYw6T!mE8S;@;d~0hc436;YH`>
z(EX+(2Pg8@bH^x$iEQ{wcutDjpr-wpf@MpcZJ@NCfHKLJJFkNV@8v4Kr^nICjqs6I
z
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
CXg`_&
literal 0
HcmV?d00001
diff --git a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md
index c01ec945f..074037ad1 100644
--- a/docs/plans/domain-architecture-and-multimodal/execution-ledger.md
+++ b/docs/plans/domain-architecture-and-multimodal/execution-ledger.md
@@ -920,8 +920,45 @@ No backend, scheduler or audit behavior changed. This repair remains local until
the required PR94 integration/current-main qualification.
Hosted PR95 source/build/native-IPC checks passed on `802544a0`; the required
-audit failed with an actual advisory-bulk HTTP 503. PR94 likewise remains blocked
-on audit service failures. No audit retries or gate changes were requested here.
+audit failed with an actual advisory-bulk HTTP 503. PR94 was likewise blocked
+on audit service failures at that stage. No audit retries or gate changes were requested here.
Local native provisioning inspection stopped when `apt-cache policy` was denied
reading `/etc/apt/apt.conf.d/80-applied-apt-retries`; no package installation or
privileged retry was attempted. This does not qualify native startup/interaction.
+
+
+## 2026-10-22 — Serial CN-02/CN-03 reconciliation
+
+PR94 passed its required exact-head checks and merged as `46aad584`. A normal
+merge (`0acae31f`) incorporates that current main into the retained NodeLab branch;
+only plan/issue conflicts required resolution, preserving both admitted slices.
+CN-03 production registry/loop/scheduler code remains unchanged. The NodeLab
+[handoff](../../custom-nodes.md#nodelab-graph-authoring) distinguishes individual
+CN-01 authoring from future host-pinned loop-template authoring: no UI tool grants,
+optional loop ports or second execution route are introduced.
+
+The combined edit-session regression saves/reopens an exact loop pin, accepts its
+admitted catalog, preserves it with blocking diagnostics under changed/missing
+catalogs, and refuses invalid new pins without snapshot/revision mutation or
+callback invocation. Hosted coverage now explicitly discovers/runs all ten
+custom-node workflow tests. The new regression is named outside the existing
+`tool_loop` substring filter; that original fifteen-test discovery/execution gate
+is unchanged. Local exact commands discover and pass both 15 and 10 tests.
+
+The [combined qualification](evidence/nodelab-authoring/qualification.json) binds
+28 source files and retains seven receipts, including fresh 1,084 library +65
+integration tests (1,149 total, zero failed/ignored), both CI discovery/run filters
+and styled Chromium → rebuilt Rust host → filesystem interaction. All eight
+browser claims pass, including failed catalog refresh/recovery without losing
+saved draft/path. The final frontend suite passes 740 tests; formatting,
+typecheck, full/critical/a11y lint, production frontend build, scheduler-only guard
+and 28 traceability tests pass. Original browser, refresh-red and missing-GLib
+receipts remain preserved. Final package all-target Clippy passes with only the
+existing inference dead-field warning. Local native startup/interaction remains unqualified;
+the recorded APT read denial was not retried or bypassed.
+
+At this qualification stage, PR94 post-merge workflows and PR95 final independent
+review/fresh exact-head CI/current-main rechecks still gate normal integration.
+No old-head audit retry, dependency suppression, lockfile change, text-readiness
+edit or branch deletion was performed. PR92's unique trained-text source/evidence
+and missing T3 gate remain separate.
diff --git a/docs/plans/domain-architecture-and-multimodal/plan.md b/docs/plans/domain-architecture-and-multimodal/plan.md
index 782bb67f8..96591c6cd 100644
--- a/docs/plans/domain-architecture-and-multimodal/plan.md
+++ b/docs/plans/domain-architecture-and-multimodal/plan.md
@@ -360,6 +360,20 @@ transfers exact session IDs to an app-scoped, user-retryable cleanup owner. Stan
continues, adding routed Frontend, TypeScript/Async, Accessibility, IPC,
Persistence, protocol/evolution and GUI verification detail to the CN-01 routes.
+**Serial reconciliation (2026-10-22):** CN-03 PR94 is integrated as `46aad584`.
+Continue CN-02 on that current-main base with the same production contract.
+Preserve both admitted slices and CN-03 source unchanged. Add one edit-session
+integration regression for a saved/reopened exact loop pin: its admitted catalog
+accepts it, changed/missing catalogs retain it with blocking diagnostics, and an
+invalid new pin is rejected without mutation. This tests the shared-registry
+handoff rather than adding loop authoring or execution. Rerun both service suites,
+frontend/static gates and the real-host browser flow; bind fresh combined-source
+evidence, obtain independent review, then pass fresh exact-head CI and current-main
+rechecks. PR94 post-merge CI must also pass before PR95 merges normally.
+Add explicit hosted discovery/execution of all ten custom-node service tests;
+keep CN-03's existing fifteen-test loop gate unchanged and name the new
+edit-session regression outside that substring filter.
+
## Admitted CN-03 pure tool-loop slice (2026-10-22)
**Authority and status:** The user authorized unfinished Pantograph paths and